From 74c6b9d930c9a9d57d8e59bd5bace43f01b62aaf Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:02:40 +0000 Subject: [PATCH 01/31] android: show attachment options above the keyboard (#7570) --- .../simplex/common/views/chat/ChatView.kt | 2 +- ...-23-fix-attachment-sheet-under-keyboard.md | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 plans/2026-09-23-fix-attachment-sheet-under-keyboard.md 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 c1b3c20b71..e9dbb3b157 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 @@ -157,7 +157,7 @@ fun ChatView( ) } val attachmentOption = rememberSaveable { mutableStateOf(null) } - val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden, skipHalfExpanded = true) val scope = rememberCoroutineScope() val selectedChatItems = rememberSaveable { mutableStateOf(null as Set?) } val showCommandsMenu = rememberSaveable { mutableStateOf(false) } diff --git a/plans/2026-09-23-fix-attachment-sheet-under-keyboard.md b/plans/2026-09-23-fix-attachment-sheet-under-keyboard.md new file mode 100644 index 0000000000..672e59aed5 --- /dev/null +++ b/plans/2026-09-23-fix-attachment-sheet-under-keyboard.md @@ -0,0 +1,60 @@ +# Fix attachment options hidden under the keyboard + +## Problem + +On Android, when the keyboard is already open and the user taps the attachment +(paperclip) button in a chat, the sheet with the Camera / Image / Video / File +options opens partly or fully under the keyboard. With the keyboard closed it opens +correctly. + +## Cause + +The sheet is a Material `ModalBottomSheetLayout` (`ChatView.kt`, `ChatLayout`) whose +state is created in `ChatView` as +`rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)`, leaving +`skipHalfExpanded` at its default `false`. + +The sheet content, `ChooseAttachmentView`, pads itself with +`.navigationBarsPadding().imePadding()`, so while the keyboard is open the sheet's +height is the buttons row plus the keyboard height, and the buttons are at the top of +the sheet. + +In Material 1.8.2 (`androidx.compose.material:material`, `ModalBottomSheet.kt`): + +- `modalBottomSheetAnchors` adds a `HalfExpanded` anchor at `fullHeight / 2` whenever + `!isSkipHalfExpanded && sheetSize.height > fullHeight / 2`; +- `show()` animates a hidden sheet to `HalfExpanded` when that anchor exists, and to + `Expanded` otherwise. + +With the keyboard closed the sheet is short, there is no `HalfExpanded` anchor, and +it opens `Expanded`. With the keyboard open the sheet is taller than half the +layout, so it stops at `HalfExpanded`: its top is at the middle of the screen, and +the buttons row below it lands under the keyboard's top edge. + +## Fix + +Pass `skipHalfExpanded = true` to the attachment sheet's +`rememberModalBottomSheetState`. `show()` then always goes to `Expanded`, so the +sheet's bottom sits at the bottom of the layout and the `imePadding` places the +buttons directly above the keyboard. + +The half-expanded state has no use for this sheet: it holds a single row of buttons +and no scrollable content. + +## Scope / non-goals + +- Only the chat attachment sheet is changed. The other `ModalBottomSheetLayout`s + (`GroupProfileView`, `UserProfileView`, `AddGroupView`, `AddChannelView`, + `WelcomeView`) use the same default state but are not part of this report. +- The two preview usages of `rememberModalBottomSheetState` in `ChatView.kt` are + left as they are. +- Desktop: the attachment sheet is shared code, but desktop has no software + keyboard inset, so the sheet is never taller than half the window and behaviour + is unchanged. +- iOS was not checked; it does not use this code. + +## Verification + +Debug arm64 APK built from this branch; on a device, open the keyboard in a chat +and tap the attachment button: the options are shown directly above the keyboard. +With the keyboard closed the sheet opens as before. From 2e56d32b52dd8cde726f0a997effc6c8bc4d7f7b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 10 Oct 2026 17:48:45 +0100 Subject: [PATCH 02/31] core: badge presentations in profiles (#7573) * badge presentation in groups * fix tests * badge presentations in profiles * update simplexmq * change types * badge profile presentations * improvements * remove proof from cards, send owner public key for channel to relay * add test * merge migrations * update * prevent fallback to PHTest * fixes * many fixes * fixes * fixes * cleanup * improve * query plans * fix test * diff --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../src/Directory/Store.hs | 6 +- bots/src/API/Docs/Types.hs | 2 +- cabal.project | 2 +- docs/protocol/channels-protocol.md | 6 +- docs/protocol/simplex-chat.md | 2 +- docs/protocol/simplex-chat.schema.json | 3 + ...026-09-04-badge-binding-and-file-limits.md | 422 ++++++++++++- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 + src/Simplex/Chat/Badges.hs | 72 ++- src/Simplex/Chat/Library/Commands.hs | 159 ++--- src/Simplex/Chat/Library/Internal.hs | 161 +++-- src/Simplex/Chat/Library/Subscriber.hs | 331 +++++----- src/Simplex/Chat/Protocol.hs | 12 +- src/Simplex/Chat/Store/Connections.hs | 12 +- src/Simplex/Chat/Store/ContactRequest.hs | 20 +- src/Simplex/Chat/Store/Direct.hs | 36 +- src/Simplex/Chat/Store/Groups.hs | 174 ++++-- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260925_badge_bindings.hs | 37 ++ .../Store/Postgres/Migrations/chat_schema.sql | 30 +- src/Simplex/Chat/Store/RelayRequests.hs | 8 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260925_badge_bindings.hs | 36 ++ .../SQLite/Migrations/agent_query_plans.txt | 2 +- .../SQLite/Migrations/chat_query_plans.txt | 147 ++++- .../Store/SQLite/Migrations/chat_schema.sql | 15 +- src/Simplex/Chat/Store/Shared.hs | 40 +- src/Simplex/Chat/Types.hs | 19 +- src/Simplex/Chat/Types/Preferences.hs | 26 +- tests/BadgeTests.hs | 26 + tests/ChatTests/Groups.hs | 88 ++- tests/ChatTests/Profiles.hs | 584 +++++++++++++++++- tests/ChatTests/Utils.hs | 49 +- tests/ProtocolTests.hs | 17 +- 35 files changed, 2025 insertions(+), 531 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_bindings.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_bindings.hs diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 98226805bc..02a2d2f5be 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -74,7 +74,7 @@ import Simplex.Chat.Names (claimDomain) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Store import Simplex.Chat.Store.Groups -import Simplex.Chat.Store.Shared (GroupKeysRow, groupInfoQueryFields, groupInfoQueryFrom, mkGroupKeys, toGroupInfo_) +import Simplex.Chat.Store.Shared (GroupKeysRow, groupInfoQueryFields, groupInfoQueryFrom, mkGroupInfoKeys, toGroupInfo_) import Simplex.Chat.Types import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..), SimplexDomain) @@ -316,8 +316,8 @@ getGroupAndRegLink cc user@User {userId, userContactId} gId = (g, gksData, gr, gLink_) <- ExceptT $ firstRow (toGroupInfoKeysRegLink currentTs cxt user) ("group " ++ show gId ++ " not found") $ DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) - gks <- withExceptT groupDBError $ mkGroupKeys db cxt g gksData - pure (GIK g gks, gr, gLink_) + gks <- withExceptT groupDBError $ mkGroupInfoKeys db cxt g gksData + pure (gks, gr, gLink_) where cxt = storeCxt cc diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index ac5992770e..3adcaef783 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -298,7 +298,7 @@ chatTypesDocsData = (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkOwner, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), - (sti @GroupMember, STRecord, "", [], "", ""), + (removeField "memberBadgeProof" $ sti @GroupMember, STRecord, "", [], "", ""), (sti @GroupMemberAdmission, STRecord, "", [], "", ""), (sti @GroupMemberCategory, STEnum' (dropPfxSfx "GC" "Member"), "", [], "", ""), (sti @GroupMemberRef, STRecord, "", [], "", ""), diff --git a/cabal.project b/cabal.project index e5f2c65eda..2614f4332d 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: e24d537eaaa32230eec1fefcd957d474d7b8d4d5 + tag: ee66dd68d4429e769da7f2506a48af5887e0f7af source-repository-package type: git diff --git a/docs/protocol/channels-protocol.md b/docs/protocol/channels-protocol.md index 6a232ea2ff..fe34bf3576 100644 --- a/docs/protocol/channels-protocol.md +++ b/docs/protocol/channels-protocol.md @@ -44,11 +44,11 @@ Creating a channel involves generating cryptographic material, creating the chan When a relay receives an invitation to serve a channel, it validates the channel and creates its own relay link. This flow is currently part of channel creation; adding relays to an existing channel is planned but not yet implemented. -1. Owner sends `x.grp.relay.inv` to the relay's contact address. This message includes the relay's member ID and role, the owner's profile, and the channel's short link. +1. Owner sends `x.grp.relay.inv` to the relay's contact address. This message includes the relay's member ID and role, the owner's profile, the channel's short link, the channel's public group ID, and the owner's member key. The message is signed with the owner's member key over the public group ID and the owner's member ID. -2. Relay receives the invitation and creates a relay request record. A relay request worker processes it asynchronously. +2. Relay receives the invitation and creates a relay request record. From chat protocol version 22 of the owner, an invitation without a valid signature is ignored. The owner's badge is verified under the public group ID, the owner's member ID and the owner's member key from a signed invitation. A relay request worker processes it asynchronously. -3. The worker retrieves the channel's link data from the SMP server, extracts and validates the channel profile and owner authorization. +3. The worker retrieves the channel's link data from the SMP server, extracts and validates the channel profile and owner authorization. The request fails when the link's entity ID differs from the public group ID in the invitation, or when the owner's key in the link data differs from the owner's member key in the invitation. 4. The relay creates its own contact address link (the relay link) with the channel's entity ID in the immutable fixed data. diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index 066b465d0d..aa1c889528 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -232,7 +232,7 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.inv` message is sent to invite contact to the group via contact's direct connection and includes group member connection address. This message MUST only be sent by members with `admin` or `owner` role. Optional `groupLinkId` is included when this message is sent to contacts connected via the user's group link. This identifier is a random byte sequence, with no global or even local uniqueness - it is only used for the user's invitations to a given group to provide confirmation to the contact that the group invitation is for the same group the contact was connecting to via the group link, so that the invitation can be automatically accepted by the contact - the contact compares it with the group link id contained in the group link uri's data field. -`x.grp.acpt` message is sent as part of group member connection handshake, only to the inviting user. +`x.grp.acpt` message is sent as part of group member connection handshake, only to the inviting user. It optionally includes the invited member's group profile. The inviting user replies with `x.grp.mem.info` that includes the inviting member's group profile. `x.grp.link.inv` message is sent as part of connection handshake to member joining via group link, and contains group profile and initial information about inviting and joining member. diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index a2400e9947..4e9c3c73a0 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -568,6 +568,9 @@ "params": { "properties": { "memberId": {"ref": "base64url"} + }, + "optionalProperties": { + "profile": {"ref": "profile"} } } } diff --git a/plans/2026-09-04-badge-binding-and-file-limits.md b/plans/2026-09-04-badge-binding-and-file-limits.md index 72890d8ac7..2783064a88 100644 --- a/plans/2026-09-04-badge-binding-and-file-limits.md +++ b/plans/2026-09-04-badge-binding-and-file-limits.md @@ -11,14 +11,14 @@ The same badge raises the size limit for files the user sends. Today each app de The changes: -1. Three new presentation headers: one for a profile shown in a chat, one for a file invitation, one for a file description. The random header stays valid where there is no chat yet, and for the profile in direct chats until the direct binding reaches the handshake. -2. In p2p groups a badge is accepted only from a message signed by the member. The member connection handshake is signed in both directions, and the profile sent in it is stored, so the badge appears when two members connect. -3. In channels a badge is accepted from any profile message, because member keys there come from the roster, the member list signed by the channel owner. +1. Five new presentation headers: a profile shown in a chat, a file invitation, a file description, a contact request, and a link. The random header of released clients stays accepted until v7.2. +2. Every profile badge is bound to the chat it is shown in: a direct chat, a contact request, a link, or a group membership (section 14). A proof bound to another chat is ignored. +3. In p2p groups a badge is accepted from a message signed by the member, from a join request under the request header, and from an introduction under the key in the introduction. In channels it is accepted from a message signed by the member and from the relay's introduction under the key stored for the member: the key from the owner-signed roster for roles from member up, the key in the introduction for lower roles. 4. Files above the default limit include a proof in the invitation and a proof in the description, in every chat type. The core library verifies both. The decision is stored on the file and shown by the apps from one field. 5. Forwarding a file above the forwarder's limit is refused with an alert before the forwarding sheet opens, and again, for the chosen destination, before anything is uploaded. 6. A received file keeps its two proofs, so a file re-sent to a new member as part of history keeps them; the sender's own files get fresh proofs from the credential. -Two new columns on `files`, and a new table `file_badge_proofs` holding the invitation proof and the description proof of a file, kept for history. One new function in simplexmq, the hash of the fields shared by all descriptions of one upload. +Two new columns on `files`, and a new table `file_badge_proofs` holding the invitation proof and the description proof of a file, kept for history. A new table `group_member_badge_proofs` holding the proof a member presented in a group, forwarded in introductions. A new column on `connections`, the request header of a prepared connection. The relay invitation includes the channel's public group id and the owner's member key. In simplexmq: the hash of the fields shared by all descriptions of one upload, the verification codes of a connection, links and invitations before link data is signed, and the minimum SMP version raised to 15. ## Terms @@ -46,7 +46,7 @@ Two new columns on `files`, and a new table `file_badge_proofs` holding the invi **Presentation header.** A byte string that is an input to proof generation and to proof verification. A proof verifies only with the header it was generated with. Type `ProofPresHeader` in `Badges.hs`. `PHTest` is a random nonce. -**Chat binding.** The byte string that identifies the sender in one conversation, produced by `encodeChatBinding` (`Protocol.hs:444`). Message signatures and shared contact cards are computed over it. For a direct chat it is `encodeChatBinding CBDirect adHash`, where `adHash` is the hash of the connection's ratchet data, which both sides obtain with `getConnectionRatchetAdHash`. For a p2p group it is `encodeChatBinding CBGroup (smpEncode (memberId, memberKey))`. For a channel it is `encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))`. `groupBindingData` (`Internal.hs:2261`) computes the inner part for groups. +**Chat binding.** The byte string that identifies the sender in one conversation, produced by `encodeChatBinding` (`Protocol.hs:444`). Message signatures and shared contact cards are computed over it. For a direct chat it is `encodeChatBinding CBDirect codeAD`, where `codeAD` is the hash of the connection's ratchet data, which both sides obtain with `getConnectionVerifyCodes`. For a p2p group it is `encodeChatBinding CBGroup (smpEncode (memberId, memberKey))`. For a channel it is `encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))`. `groupBindingData` (`Internal.hs:2322`) computes the inner part for groups. **Member key.** The Ed25519 key a member holds for one group. It is created when first needed — at group creation on this branch, or by `createUserMemberKey` before the first signed message — and the public key is sent in introductions and in `XInfo`. @@ -64,10 +64,14 @@ data ProofPresHeader | PHChat ByteString | PHFileInv ByteString String Integer | PHFileDescr ByteString String Integer ByteString (Maybe UTCTime) + | PHRequest ByteString + | PHLink ByteString | PHUnknown Char ByteString ``` - `PHChat` holds the chat binding. +- `PHRequest` holds the request code (section 14.1). +- `PHLink` holds the link key. - `PHFileInv` holds the chat binding and the file size from the invitation. - `PHFileDescr` holds the same two values, then the shared description hash (section 8) and the file expiration. @@ -75,57 +79,63 @@ The file name is not part of either header: `validateFileInvitation` replaces it One constructor serves every chat type, because the chat binding already encodes the type of chat in its first byte. Each constructor gets a tag character in `ProofPresHeaderTag` and an encoding in the `StrEncoding` instance, in the same style as `PHTest`. The file expiration is optional, because a server may grant none; it is encoded as `strEncode` of the time, or one fixed byte when absent. The badge's own expiry is a time and is encoded with `strEncode` in the disclosed messages (`badgeInfoMessages`, `Badges.hs:296`). -`verifyBadgeWith` today verifies a proof with whatever header the proof contains. After this change the receiver first checks that the header names the sender as the receiver knows them, and only then runs BBS verification with that header. `proofPresHeaderAccepted` is removed. What the receiver knows is already held by existing types, so no new type is added: +`verifyBadgeWith` today verifies a proof with whatever header the proof contains. After this change the receiver first checks that the header names the sender as the receiver knows them, and only then runs BBS verification with that header. `proofPresHeaderAccepted` is removed; only BBS verification is run by `verifyBadge`, and the header rule is applied by its callers: -- A contact request, link data, and the profile in a direct chat: the header must be `PHTest`. -- A file in a direct chat: the receiver has the contact's connection and obtains its ratchet hash from the agent, as `newContentMessage` does for a contact card (`Subscriber.hs:1883`). The binding in the header must equal `encodeChatBinding CBDirect adHash`. -- A profile or a file in a group: the receiver has the `GroupInfo` and the sender's `GroupMember`. The binding in the header must equal `groupBindingData` for that member — for a channel the group's public id and the member id; for a p2p group the member id and a key that passes the key check. +- A contact request, link data, and the profile in a direct chat: the header of section 14. +- A file in a direct chat: the receiver has the contact's connection and obtains its ratchet hash from the agent, as `newContentMessage` does for a contact card (`Subscriber.hs:1883`). The binding in the header must equal `encodeChatBinding CBDirect codeAD`. +- A file in a group: the receiver has the `GroupInfo` and the sender's `GroupMember`. The binding in the header must equal `groupBindingData` for that member — for a channel the group's public id and the member id; for a p2p group the member id and the member key. +- A profile in a group: for a channel the group's public id, the member id and the member key; for a p2p group the member id and the member key. -**The key check.** A p2p binding contains the sender's member key. The receiver may know that member's key from the introduction or from a signed message, or may not know it yet. If the receiver knows a key and it differs from the key in the header, the proof fails. Otherwise the key in the header is used for this verification and never stored; keys are stored only by the introduction and by `storeMemberKey`. +**The key in a p2p binding.** For a file the receiver may not know the sender's key yet; if it knows one and it differs from the key in the header, the proof fails, otherwise the key in the header is used for this verification and never stored (`proofMemberKey`). For a profile the key is the one the message signature was verified under, section 3; a header key is never used. The file headers are checked the same way and then further: `PHFileInv` must also name the file size as received; `PHFileDescr` must also hold the hash of the received description and the expiration received with it. -`PHUnknown` fails every check. A proof from a released client, which presents `PHTest` in groups, fails in groups; no badge has been issued yet, so nothing in use is affected. A released client that receives one of the new headers verifies it, because its `proofPresHeaderAccepted` admits unknown tags and BBS verification runs with the header bytes as sent. No protocol version change is needed. +`PHUnknown` fails every check. `PHTest`, presented by released clients, is accepted everywhere; its acceptance is dropped in v7.2. A released client that receives one of the new headers verifies it, because its `proofPresHeaderAccepted` admits unknown tags and BBS verification runs with the header bytes as sent. No protocol version change is needed. -`groupBindingData` moves from `Internal.hs` to `Protocol.hs`, beside `encodeChatBinding`, because the store modules import `Protocol` and not `Internal`. Module order fixes where the check is computed: `Badges.hs` is imported by `Types.hs`, which `Protocol.hs` imports, so the header check in `Badges.hs` takes plain values — the expected binding for a channel or a direct chat, or the member id and the stored key for a p2p group — and the callers in the store compute them from `GroupInfo` and `GroupMember` with `groupBindingData`. `profileBadgeVerified` is in `Types.hs` today and cannot call `groupBindingData`; it moves to `Store/Shared.hs`, beside the other badge-verifying store code. +The expected header is computed in the chat layer and passed to the store; `groupBindingData` and `profileBadgeVerified` remain in `Internal.hs` and `Types.hs`. One predicate is defined in `Badges.hs`: `acceptedProof`, true for `PHTest` and for a header equal to the expected one (section 14.3). `SimplexDomainProof` (`Names.hs:37`) also uses `ProofPresHeader`, as an opaque value. Its verification is unchanged. ## 2. Presenting the profile badge -File: `src/Simplex/Chat/Library/Internal.hs`, `presentUserBadge` (`:2178`). +File: `src/Simplex/Chat/Library/Internal.hs`, `presentUserBadge` (`:2238`). -The function generates the proof for an outgoing profile. It takes a new argument, `Maybe GroupInfo`. With `Nothing` it generates `PHTest` as today. With `Just gInfo` it generates `PHChat` from the group's chat binding and the user's own member key in that group, calling `createUserMemberKey` first when the group has no key yet. +The proof for an outgoing profile is generated with `sndBadgeProof`, as file proofs are. The header, `Maybe ProofPresHeader`, is a parameter; with `Nothing`, no badge is presented. For a send into a group the header is `groupPresHeader`, `memberPresHeader` of the user's own member id and key in that group. The membership key is generated and stored by `mkGroupInfoKeys` when the group is read with its keys, and it is set on the membership of the returned group; for a membership without a public key, no badge is presented. The header of every call site is listed in section 14.3. -Call sites that send a profile into a group pass the group: `Commands.hs:3953` (join via group link, the group case), `:4291` (the owner's profile to a relay); `Subscriber.hs:480` (the group case), `:611`, `:799`, `:813`, `:941`, `:1220`, `:3271`; `Internal.hs:2539` (`sendGroupProfileUpdate`). All other call sites send a direct profile and pass `Nothing`. - -The profile in a direct chat keeps `PHTest`; moving it to `PHChat` is a later change. +In the handshake sends (section 4) and in `XGrpAcpt` (section 14.3, item 9), the badge is presented at every peer version; the message is signed only when the peer version is at least `relayWebCapVersion`. A peer below `relayWebCapVersion` does not store the proof; the proof is generated for it anyway, and no version check is added for it. ## 3. Accepting the profile badge -A received badge is verified today at seven places in the store layer, each verifying the proof with no knowledge of the sender: `profileBadgeVerified` (`Types.hs:834`), `createContact_` (`Store/Shared.hs:420`), `createJoiningMember` (`Store/Groups.hs:2089`), `createNewMemberProfile_` (`Store/Groups.hs:2459`), two contact request sites (`Store/ContactRequest.hs:169, 236`), and `linkDataBadge` (`Internal.hs:2194`). +A received badge is verified today at seven places in the store layer, each verifying the proof with no knowledge of the sender: `profileBadgeVerified` (`Types.hs:851`), `createContact_` (`Store/Shared.hs:421`), `createJoiningMember` (`Store/Groups.hs:2085`), `createNewMemberProfile_` (`Store/Groups.hs:2440`), two contact request sites (`Store/ContactRequest.hs:170, 237`), and `linkDataBadge` (`Internal.hs:2253`). -The direct sites keep verifying with `PHTest`. The group sites gain the `GroupInfo` and the sender's `GroupMember` where they do not have them already: `updateMemberProfile` and `updateContactMemberProfile` (`Store/Groups.hs:3430, 3453`) have the member and gain the group, and pass both to `profileBadgeVerified`; `createNewMemberProfile_` gains both from `createNewGroupMember`. A badge from a message that was not verified with the member's key is not verified at all: the caller removes it from the profile before storing, so the store function sees no badge. +The expected header, `Maybe ProofPresHeader`, is a parameter of the store functions and is checked with `acceptedProof` (section 14.3); `Nothing` is passed where a profile is built from a name alone. A proof rejected by `acceptedProof` is ignored: at creation no badge is stored, at update the stored badge is kept. A proof that fails BBS verification is stored as failed. -Where a badge is accepted in a p2p group: +The header is computed in the chat layer by `memberPresHeader gInfo memberId key_` (`Internal.hs`): for a channel `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, key)))`; for a p2p group `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, key)))`; `Nothing` without a key. File proofs keep the binding of message signatures (`rcvGroupChatBinding`). The key passed at each site: -- `xInfoMember` (`Subscriber.hs:2738`): only when the `XInfo` was verified with the member's key. `RcvMessage.msgSigned` is `MSSVerified` when a stored key verified it. When the same message delivers the key, `storeMemberKey` has verified the signature with that key, and the badge is kept on the same basis. Otherwise the badge is removed from the profile before `processMemberProfileUpdate`. -- `xGrpLinkMem` (`:2744`): the host's profile to the joiner, signed on this branch. -- The member connection handshake, section 4. +- `xInfoMember` (`Subscriber.hs:2798`) and `xGrpLinkMem` (`:2804`): the stored key when the message signature is verified with it, otherwise no key. A key delivered by the message is first stored by `storeMemberKey` when the signature is verified with it; the member with the stored key is returned by `storeMemberKey`. +- The member connection handshake, section 4: the stored key when the signature is verified with it. +- Introductions — `createNewGroupMember`, `createIntroReMember`, `updateUnknownMemberAnnounced`, `updateRosterMemberAnnounced`, `updatePreparedChannelMember`: in a channel the key stored for the member after the introduction — the roster key for roles from member up, the key in the `MemberInfo` for lower roles; in a p2p group the key in the `MemberInfo`. +- A join via a relay (`createJoiningMember`, `updateMemberProfile`): the key in `XMember` when the message signature is verified with it. +- The channel owner at a relay (`createRelayRequestGroup`, `relayInvPresHeader`): the owner's key claimed in the invitation, when the invitation is signed with it. +- `XGrpAcpt` at the inviting host: the key delivered by the message, stored before the profile; the binding includes it when the message signature is verified with it. +- A join by `XContact` to a p2p group: the request header (section 14.3). +- A relay's profile, from its link data: `Nothing`. -Where a badge is dropped in a p2p group: `createJoiningMember` and `createNewMemberProfile_`. The profile is stored without the badge, and the member's badge arrives at the handshake. - -In a channel a member's profile arrives in three ways: in `XMember` when the member joins, which the member signs and the owner verifies with the roster key (`verifyKey`, `Subscriber.hs:1656`) before `createJoiningMember`; in the introduction from a relay, stored by `createNewMemberProfile_`; and in `XInfo`. A badge in any of them is kept and verified, because member keys in a channel are established by the roster, which the owner signs, and `xGrpMemNew` rejects a relay that asserts a different key (`Subscriber.hs:3127-3134`). +In a channel a badge is accepted from `XMember` and `XInfo` whose signature was verified under the member's key, and from the introduction by a relay under the key stored for the member (section 14.3, item 9). In a p2p group a badge is accepted from a message whose signature was verified under the member's key, from a join request under the request header, and from an introduction under the key in the introduction. ## 4. The member connection handshake -When two p2p members connect, each sends `XGrpMemInfo` with its group profile. It is sent from two places: the reply on the member connection (`Subscriber.hs:816`) and the join of the member connection and of the direct connection to the same member (`:3272`, both joined with the same message at `:3282-3283`). The four receiving sites — `:590, 620` on the direct connection, `:810, 823` on the member connection — each have a "TODO update member profile" comment. +When two p2p members connect, each sends `XGrpMemInfo` with its group profile. It is sent from two places: the reply on the member connection (`Subscriber.hs:843`) and the join of the member connection and of the direct connection to the same member (`:3336`, both joined with the same message at `:3345-3347`). The four receiving sites — `:615, 647` on the direct connection, `:837, 850` on the member connection — each have a "TODO update member profile" comment. -- **Sign the join side.** `xGrpMemFwd` sends `encodeConnInfo $ XGrpMemInfo ...` (`:3272`), plain JSON. Change it to `encodeSignedConnInfo` with `groupMsgSigning` when the agreed version is at least `relayWebCapVersion`. The agreed version is computed three lines below, as `chatV`; move that computation above the send. Call `createUserMemberKey` before signing, here and at the reply site, as every other signing site does. -- **Parse the signature on CONF.** The member CONF site parses with `parseChatMessage` (`:745`), which discards the signature. Change it to `parseChatMessage'`, as INFO already does (`:823`). -- **Verify the signature.** At `:810` and `:823` verify the signed `XGrpMemInfo` with the member's stored key. `XGrpMemInfo` names no key, and the handshake follows the introduction, which stored the key. A member with no stored key is not verified. -- **Store the profile.** At `:810` and `:823` call `processMemberProfileUpdate` with the profile, with the badge removed when the signature did not verify. -- `:590` and `:620` stay as they are. The profile there is the same group profile, received over the direct connection to the member. The contact for a member shares the member's profile row (`createIntroToMemberContact`, `Store/Groups.hs:2684-2685`), so storing it once, on the member connection, updates both. +The badge is presented in `XGrpMemInfo` at every peer version, by `membershipHandshakeProfile`. `XGrpMemInfo` is signed by `groupMsgSigning` when its profile includes a badge and the peer version is at least `relayWebCapVersion`. A `PHChat` proof is kept only from a verified signature. + +- **Sign the join side.** In `xGrpMemFwd` (`:3336`), `XGrpMemInfo` is encoded with `encodeSignedConnInfo` when a signing is returned by `groupMsgSigning` and the agreed version, `chatV`, is at least `relayWebCapVersion`; `GroupInfoKeys` is passed from the dispatch. +- **The reply side** (`:843`): the message is signed by `allowAgentConnectionAsync` when the maximum of the connection's `peerChatVRange` is at least `relayWebCapVersion`. +- **Parse the signature on CONF.** The member CONF site (`:781`) is parsed with `parseChatMessage'`, as INFO is (`:847`). +- **Verify and store.** At `:837` and `:850` the signed message is passed to `processMemberProfileUpdate`, and the signature is verified by `signedMemberPresHeader` with the stored member key: the key from the introduction, or, at `:850` for the inviting host, the key from `XGrpInv`. The profile is stored with the binding of the verified key, or with no binding. +- `:615` and `:647` are unchanged: the profile there is the same group profile, received over the direct connection to the member, and the member's profile row is used by the contact for the member (`createIntroToMemberContact`). + +For a member whose key was never introduced, a `PHChat` proof is accepted only from a message that delivers the key and is signed with it. ## 5. The file size limit at send @@ -254,11 +264,355 @@ The six proof columns are the fields of `BadgeProof` — the proof, the presenta ## 13. Tests - `BadgeTests.hs`: each header encodes and decodes; a proof generated with one header fails with another; the key check accepts an unknown key, accepts an equal key, rejects a different one. -- `ChatTests/Profiles.hs`, beside the seven badge tests: a badge in a p2p group appears at the other member after the connection handshake and not before; a proof presented under another member's binding is rejected; a badge in a channel appears on presentation. Existing tests that assert a badge at introduction time are updated. +- `ChatTests/Profiles.hs`, beside the seven badge tests: a badge in a p2p group is shown at the other member from the introduction and from the connection handshake; a proof presented under another member's binding is ignored; a badge in a channel is shown on presentation. The tests of section 14.3 follow. - `ChatTests/Files.hs`, beside `testXFTPGroupFileTransfer`: a file above the default limit from a badge holder is received in a group and in a direct chat; an invitation whose proof was made for another member is refused; a description with a changed hash fails before download; a file above the limit received as history is received by the new member; a forward into an incognito membership above the default limit fails the command before any upload. - `ProtocolTests.hs`: the new fields in `FileInvitation` and `XMsgFileDescr`. +## 14. Bindings in every chat + +Every profile badge is bound to the chat it is shown in. The sender presents the proof under the header of that chat; the receiver computes the expected header and accepts the proof under it, and under `PHTest`. Implementation starts in simplexmq, in `/code/simplexmq-4`; the chat then builds against it. Line references in this section are to the branch; in sections 1-13, to master. + +Rules: + +1. A ratchet held by both sides: the ratchet code. +2. A connection request before the ratchet: the request code — the proposing party's X448 keys, its PQ key when present, and the id of the queue the joining party sends to. +3. A link shown to anyone who holds it: the link key. +4. A group: the member identity. + +| Context | Received by | Header | +|---|---|---| +| Direct chat: `XInfo`, `CONF` and `INFO` replies, accept, one-time link join, member contacts | contact | `PHChat (encodeChatBinding CBDirect (sha256 ("SimpleX badge" <> codeAD)))` | +| Request to an address with ratchet keys | address owner | `PHChat (encodeChatBinding CBDirect (sha256 ("SimpleX badge" <> codeAD)))` | +| Request to an address without ratchet keys | address owner, group host | `PHRequest code` — the joining party's keys, the address queue | +| One-time invitation link data | joining party | `PHLink linkKey` | +| Address link data | anyone with the link | `PHLink linkKey` | +| P2p group | members | `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, memberKey)))` | +| Channel | subscribers | `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, memberKey)))` | + +An address is a contact address, a business address, or a group link. The link key is part of the link: `sha3_256` of the fixed link data — the agent version range, the root key, the connection request with its server and queue id, and the entity id of an address (`encodeSignFixedData`, `ShortLink.hs`). + +### 14.1 Agent: verification codes + +Merged in simplexmq `5294b7d8`, except the ratchet stored by `newConnToAcceptDR`, its use in `startJoinInvitationDR`, and their tests. + +**Second verification code.** `RatchetInitParams` gains `rcVerifyCodePQ` and `Ratchet` gains `rcVCPQ :: Maybe Str`. The code is derived in `pqX3dh` with a separate HKDF over the same inputs, info `SimpleXVerifyCode`, 32 bytes; the ratchet keys and `rcAD` are unchanged. The code is exported keying material over every handshake input, the KEM included — the fourth of the paper's mitigations. A ratchet created before this change is decoded with `rcVCPQ = Nothing`, and its code is set at the next ratchet resync. The chat uses the AD code, `codeAD`, for the security code and for badge bindings; `codePQ` is stored. + +**Columns.** Migration `M20260919_ratchet_verify_codes`, SQLite and Postgres, both schema dumps: + +```sql +ALTER TABLE ratchets ADD COLUMN rc_verify_code_ad BLOB; +ALTER TABLE ratchets ADD COLUMN rc_verify_code_pq BLOB; +``` + +`BYTEA` on Postgres. Both are written by `createRatchet` and `createSndRatchet`, also through their `ON CONFLICT` update, by which a ratchet is recreated at a resync. + +One store function, `getRatchetVerifyCodes :: DB.Connection -> [ConnId] -> IO (Map ConnId ConnVerifyCodes)`, is used for one id or many: + +```sql +SELECT conn_id, rc_verify_code_ad, rc_verify_code_pq, CASE WHEN rc_verify_code_ad IS NULL THEN ratchet_state END +FROM ratchets +``` + +- A row with `rc_verify_code_ad` is used as is. +- A row with the ratchet state is decoded, and its codes are computed and saved. +- A row with x3dh keys only is skipped. +- SQLite: one SELECT per id; the computed codes are saved with one `executeMany` UPDATE. +- Postgres: one SELECT with `In connIds`; the computed codes are saved with one `UPDATE ... FROM (VALUES ...) RETURNING`; codes saved by a concurrent read are kept. + +It is used by `getConnectionVerifyCodes` with one id, with `CONN NOT_FOUND` for a connection without codes, and by `getConnectionsVerifyCodes` with many. The result is `ConnVerifyCodes {codeAD, codePQ}`, defined in `Agent/Protocol.hs`, where `codeAD = sha256 rcAD` and `codePQ` is the PQ code as derived. + +**Prepare step.** The result of `prepareConnectionToJoin` is `(ConnId, ContactRequestBinding)`, the binding for the message the chat composes next: + +```haskell +data ContactRequestBinding = CRBRatchet ConnVerifyCodes | CRBRequest ByteString +``` + +- `CRInvitationUri`: the sender ratchet is created (`createRatchet_`, local — the link's keys and a fresh keypair); the binding is `CRBRatchet` of its codes. +- `CRContactUri` with ratchet keys: the same, from the address keys. +- `CRContactUri` without keys: the x3dh keys are generated and stored (`generateRcvE2EParams`, `createRatchetX3dhKeys`); the binding is `CRBRequest (sha256 (smpEncode (k1, k2, kem, senderId)))` — the request's public keys and the queue id from the link's `SMPQueueUri`. + +The same pair is returned by `prepareConnectionToAccept`: for `CRInvitation` the ratchet is created from the invitation's keys, for `CRInvitationDR` the ratchet in the invitation is stored with the connection by `newConnToAcceptDR`. In `startJoinInvitation` the stored ratchet is used, and a ratchet is created only for a connection without one, as in the contact path and its retry branch. In `startJoinInvitationDR` the ratchet in the invitation is written by `createRatchet` after `updateNewConnSnd`, in one transaction: it is written only while the connection has no send queue, and a ratchet used in a send is never replaced. In `createConnReq` the x3dh keys are read before they are generated, as in `mkJoinInvitation`. The stored ratchet is used by async joins and accepts. The prepare step is local. + +**Events.** A `ContactRequestBinding` field in `REQ`: `CRBRequest` in `smpInvitation`, computed from the received `CRInvitationUri` and the queue of the request; `CRBRatchet` in `smpContactRequest`, from the ratchet initialised there. `CONF` and `INFO` are unchanged: the receiver's ratchet is stored before the notification, so its codes are available to `getConnectionVerifyCodes`. + +**Tests.** `DoubleRatchetTests`: the parties agree on `rcVerifyCodePQ`, a substituted KEM key makes it differ while `assocData` matches, and a ratchet stored before the change decodes with `rcVCPQ = Nothing`. `FunctionalAPITests`: both peers get the same codes, and codes cleared from a row are recomputed and saved on the next read. For an address with ratchet keys, the codes of the requester's prepare step, of `REQ`, of the acceptor's prepare step and of the stored ratchet are equal before `acceptContact`; an accept of a prepared connection without a ratchet is completed by `acceptContact`. + +### 14.2 Agent: links before link data + +Files: `Agent.hs`, `Agent/Client.hs`, `Agent/Protocol.hs`, `Crypto/ShortLink.hs`, `Protocol.hs`, `Transport.hs`, `tests/AgentTests/FunctionalAPITests.hs`. + +The chat pins the simplexmq commit in `cabal.project` and `scripts/nix/sha256map.nix`. + +A link prepared by `prepareConnectionLink` is available to the chat before its link data is signed: a contact link with its short link, an invitation as its full link and its link key; one network request is made per creation. + +**New links.** The contact link with its short link, or the invitation with its link key `plpLinkKey`, is returned by `prepareConnectionLink` before the user data is signed; the short link of an invitation, with the link id assigned by the server, is returned by `createConnectionForLink`: + +```haskell +prepareConnectionLink :: AgentClient -> UserId -> SConnectionMode c -> C.KeyPairEd25519 -> Maybe ByteString -> Bool -> Maybe CRClientData -> CR.InitialKeys -> UseRatchetKeys -> Maybe SMPServerWithAuth -> AE (CreatedConnLink c, PreparedLinkParams c) + +createConnectionForLink :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> CreatedConnLink c -> PreparedLinkParams c -> UserConnLinkData c -> SubscriptionMode -> AE (ConnId, CreatedConnLink c) +``` + +- The link entity id is optional; `Nothing` for an invitation. +- `PreparedLinkParams` is indexed by the connection mode. The ratchet keys are one field, `plpRatchetKeys :: PreparedRatchetKeys m`, in place of `plpAddressKeys`: + + ```haskell + data PreparedRatchetKeys (m :: ConnectionMode) where + PRKInvitation :: RcvE2EPrivRatchetParams 'C.X448 -> PreparedRatchetKeys 'CMInvitation + PRKContact :: Maybe (RatchetKeyId, RcvE2EPrivRatchetParams 'C.X448) -> PreparedRatchetKeys 'CMContact + ``` + +- Client notices are checked for contact mode only. +- Contact mode: the link given to `createConnectionForLink` is returned. +- Invitation mode, prepare: + - x3dh keys from `CR.generateRcvE2EParams`, PQ support from `CR.initialPQEncryption True pqInitKeys` + - `connReq = CRInvitationUri crData e2eRcvParams`, queue mode `QMMessaging` + - the returned link is `CCLink connReq Nothing`; the key is `plpLinkKey` + - `useDR` is ignored +- Invitation mode, create: + - link data: `SL.encodeSignUserData SCMInvitation`, encrypted by `encryptInvLinkData` with `SL.invShortLinkKdf plpLinkKey`; `newRcvConnSrv` uses the same function + - queue request: `CQRMessaging (Just CQRData {linkKey, privSigKey, srvReq = (sndId, srvData)})` +- In both modes the link data is encrypted before the connection is created, and the queue is created by the local `createLinkQueue`: + - the ratchet keys are stored: `PRKContact` keys by `storeAddressRatchetKeys`, `PRKInvitation` keys by `createRatchetX3dhKeys` + - `createRcvQueue` + - the connection is deleted when either step fails + - the returned link from `connReqWithShortLink`, moved from `newRcvConnSrv` to top level, with the created `RcvQueue` as a parameter — `CSLInvitation` with the link id from the server, PQ keys removed from the full link for `IKPQOn` +- Tests: + - a connection via an invitation made by prepare and create; its link data is read by the joining party; `plpLinkKey` equals the key in the returned `CSLInvitation` + - a contact link returned by `createConnectionForLink` equals the prepared link + - for link data above the size limit, `CMD LARGE` is returned in both modes and no connection is stored + - the six existing test calls are updated for the mode and the pair + +**Existing connections.** The link of a connection is returned without a network call: + +```haskell +prepareConnShortLink :: AgentClient -> ConnId -> Maybe CRClientData -> AE (ConnShortLink 'CMContact) +``` + +- A stored link is returned as is. +- Otherwise the credentials are created and stored by `newContactLinkCreds :: AgentClient -> RcvQueue -> Maybe CRClientData -> AM (ShortLinkCreds, C.SbKey)`: the signing key pair is generated, the fixed data is built from the connection request without ratchet keys, signed and encrypted (`SL.encryptFixedData`), and `ShortLinkCreds` are stored; the link data key is returned with them. +- In `setConnShortLink`, `newContactLinkCreds` is used for a connection without stored credentials, and the user data is then encrypted with the returned key and uploaded with one `LSET`. For stored credentials, the key is derived once, by the link id check. +- Tests: for an address without a short link, the link is the same from two `prepareConnShortLink` calls and from `setConnShortLink`; a requester connects via it. + +**SMP versions below 15.** The minimum SMP version of clients and servers is 15, and the code for older versions is removed: + +- `NEW` and `IDS` (`Protocol.hs`): the encodings and parsers for versions below 15, with `qReq` and `qm`. +- `mkShortLinkCreds` (`Agent/Client.hs`): link data without a link id in the response is an error; the `THandleParams` parameter is removed. +- `connReqWithShortLink`: absent short link credentials are an `INTERNAL` error. +- `createConnectionForLink'`: the sender id check of master, error `sender ID mismatch`, is removed; the sender id is compared by `mkShortLinkCreds` and by `connReqWithShortLink`. +- `Transport.hs`: `shortLinksSMPVersion` is renamed `_shortLinksSMPVersion` and is not exported; `_proxyServerHandshakeSMPVersion` is removed. +- Tests: `testInvitationShortLinkPrev` and `testProxyMatrixWithPrev` are removed. + +### 14.3 Chat + +In implementation order. A badge is presented only with a header: no badge is presented at a direct send for a connection without codes or for a retry without a stored request header, and at a send into a group for a `Nothing` from `groupPresHeader`. The agent is asked for codes or a link only when a badge is presented, or when a received proof has a header other than `PHTest`. An agent error is returned as a chat error. A failed read of verification codes fails the whole operation — a profile update, a badge broadcast, or a reply to a confirmation; the profile is not sent to the contacts whose codes were read, and no partial delivery is reported. + +**1. Headers** — `Badges.hs` + +- `PHRequest ByteString` in `ProofPresHeader`, tag `'R'`. +- `PHLink ByteString` in `ProofPresHeader`, tag `'L'`; payload: the link key bytes. +- `acceptedProof :: Maybe ProofPresHeader -> BadgeProof -> Bool`: true for `PHTest`, and for a header equal to `strEncode` of the expected one. `proofPresHeaderAccepted` and `verifyBadge_` are removed. +- `acceptedBadge :: Maybe ProofPresHeader -> Maybe BadgeProof -> Maybe BadgeProof -> Maybe BadgeProof`: the received proof when it is accepted or absent, otherwise the stored one. Used by `profileBadgeVerified`, `processContactProfileUpdate`, `processMemberProfileUpdate` and `setMemberBadgeProof`. +- `sameBadgeProof :: Maybe BadgeProof -> Maybe BadgeProof -> Bool`: equal disclosed information and equal headers, where the random header of `PHTest` is not compared. Used by `processMemberProfileUpdate` and `setMemberBadgeProof`. +- `unboundProof :: BadgeProof -> Bool`: true for `PHTest`. +- `ToField` and `FromField` for `ProofPresHeader`: the `strEncode` bytes as a blob. + +**2. Ignored proofs** — `Types.hs`, store modules + +- A proof rejected by `acceptedProof` is ignored. +- At creation no badge is stored: `createContact_`, `createContactRequest` (`ContactRequest.hs:170`), `createJoiningMember`, `createNewMemberProfile_`. +- At update the stored badge is kept: the badge to store and its verification are returned by `profileBadgeVerified` — for a rejected proof, the stored proof, verified again when its stored status is `BSFailed` or `BSUnknownKey`. Used in `updateContactProfile`, `updateMemberProfile`, `updateContactMemberProfile`, and the request update (`ContactRequest.hs:239`). +- `proofPresHeaderAccepted` is removed from `verifyBadgeWith`, and `verifyBadge_` is removed; at the creation sites `profileBadgeVerified` is called with no stored profile. `BSFailed` is stored only for a failed BBS verification. + +**3. Expected header in the store** + +The expected header, `Maybe ProofPresHeader`, is a parameter of: + +- `updateContactProfile` +- `createDirectContact` +- `createPreparedContact` +- `createOrUpdateContactRequest` +- `updateMemberProfile` +- `updateContactMemberProfile` +- `createJoiningMember` +- `createNewGroupMember` +- `createIntroReMember` +- `updateUnknownMemberAnnounced` +- `updateRosterMemberAnnounced` +- `updatePreparedChannelMember` +- `createRelayRequestGroup` + +`Nothing` is passed by `createContact` for the preset contact card, by `setRelayLinkAccepted` and `updateRelayMemberData` for a relay profile, by `updatePreparedUserAndHostMembers'` for a host profile built from a name, and by `acceptGroupJoinSendRejectAsync`. In `processMemberProfileUpdate` the header is computed from the signed message by `signedMemberPresHeader`. Other group headers are computed by `memberPresHeader` or `memberInfoPresHeader`; the request header is passed for a join by `XContact`, and `relayInvPresHeader` for a relay invitation. + +**4. Presenting** — `Internal.hs` + +```haskell +presentUserBadge :: User -> Maybe i -> Maybe ProofPresHeader -> Profile -> CM Profile + +presentUserBadgeWith :: User -> Maybe i -> CM (Maybe ProofPresHeader) -> Profile -> CM Profile +``` + +With `Nothing`, no badge is presented. A badge is presented only when `presentsUserBadge :: User -> Bool` holds, for a profile that is not incognito: the user's own badge is active or expired. `presentUserBadgeWith` runs the header action only in that case; `presentUserBadge` is `presentUserBadgeWith` of a known header. Header helpers: + +- `groupPresHeader :: GroupInfo -> Maybe ProofPresHeader` — `memberPresHeader` of the membership's id and key +- `directPresHeader :: ContactRequestBinding -> ProofPresHeader` — `PHChat (encodeChatBinding CBDirect (sha256 ("SimpleX badge" <> codeAD)))` for `CRBRatchet`, `PHRequest code` for `CRBRequest`. The profile header holds the labelled hash, so a profile proof does not include the input of the security code; file proofs keep `encodeChatBinding CBDirect codeAD`. +- `linkPresHeader :: ConnShortLink c -> ProofPresHeader` — `PHLink` of the link key +- `memberKeyPresHeader :: Maybe B64UrlByteString -> MemberId -> C.PublicKeyEd25519 -> ProofPresHeader` — `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, key)))` with a channel id, `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, key)))` without one +- `memberPresHeader :: GroupInfo -> MemberId -> Maybe C.PublicKeyEd25519 -> Maybe ProofPresHeader` — `memberKeyPresHeader` of the channel id in a channel and of no id in a p2p group; `Nothing` without a key +- `memberInfoPresHeader :: GroupInfo -> MemberInfo -> Maybe ProofPresHeader` — `memberPresHeader` of the id and key in the `MemberInfo` +- `relayInvPresHeader :: Maybe SignedMsg -> GroupRelayInvitation -> Maybe ProofPresHeader` — `memberKeyPresHeader` of the channel id, the owner's id and the key in the invitation, for an invitation signed with that key (item 12) +- `connPresHeader :: Connection -> CM (Maybe ProofPresHeader)` — `connsPresHeaders` of one connection +- `connsPresHeaders :: [Connection] -> CM (Map ConnId ProofPresHeader)` — `directPresHeader . CRBRatchet` of `getConnectionsVerifyCodes`; the map includes the connections with codes + +**5. The binding from prepare steps** + +The result of each includes `directPresHeader` of the agent binding: + +- `prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat, ProofPresHeader)` +- `prepareAgentJoin :: User -> Bool -> ConnectionRequestUri c -> CM ((CommandId, ConnId), ProofPresHeader)` +- `prepareAgentAccept :: User -> Bool -> InvitationId -> PQSupport -> CM ((CommandId, ConnId), ProofPresHeader)` + +**6. Stored request header** + +Migration `M20260925_badge_bindings`, SQLite and Postgres, first statement; the rest is in item 11: + +```sql +ALTER TABLE connections ADD COLUMN pres_header BLOB; +``` + +`BYTEA` on Postgres. Both schema dumps and `chat_query_plans.txt` are updated. + +- `createConnReqConnection`: the request header is a parameter, written to `pres_header`. +- `getConnReqContact`: the result is `(ConnReqContact, Maybe ProofPresHeader)`, read from `via_contact_uri` and `pres_header`. + +**7. Direct sends** + +- `joinContact` (`Commands.hs:3982`): the request header, `Maybe ProofPresHeader`, is a parameter, set in `connect'`, `joinPreparedConn'` and `connectContactViaAddress` — the `prepareContact` header for a new connection, the stored header for a retry. The badge is presented with `groupPresHeader` in a relay group, and with the request header otherwise. +- `connectViaInvitation` (`Commands.hs:3808-3845`) and `connectMemberContact` (`:3395-3422`): the prepare binding for a new connection; `connPresHeader` for a prepared one, by `presentUserBadgeWith`. +- `joinMemberContactAsync` (`Subscriber.hs:3952`): the header is a parameter, set in `xGrpDirectInv` to the `prepareAgentJoin` header. +- `acceptContactRequest` (`Internal.hs:984-1014`): the prepare binding for a new connection; `connPresHeader` for an existing one, by `presentUserBadgeWith`. +- `acceptContactRequestAsync` (`Internal.hs:1016-1035`): the profile is built after `prepareAgentAccept`, from its header. +- `CONF` replies (`Subscriber.hs:506` direct case, `:623`) and `updateContactPrefs` (`Commands.hs:4099`): `connPresHeader`, by `presentUserBadgeWith`. +- `sendUpdateToContacts` (`Commands.hs:4043-4082`) and `presentUserBadgeToContacts` (`:5215-5230`): one `connsPresHeaders` call per command, when `presentsUserBadge` holds. + +**8. Direct receipts** + +- `REQ` (`Subscriber.hs:1399`): `directPresHeader` of the `REQ` binding is a parameter of `profileContactRequest`, passed to `createOrUpdateContactRequest` and to `acceptGroupJoinRequestAsync`. +- `processContactProfileUpdate` (`Subscriber.hs:2761`) and `saveConnInfo` (`:3185`, for `createDirectContact`): `connPresHeader` of the connection the profile was received on, read only for a received proof with a header other than `PHTest`. +- In `processContactProfileUpdate` the profile with the stored proof in place of a rejected one is used for the comparison and the chat item, as for members (item 11). + +**9. Groups** + +- `groupPresHeader` at every send into a group: `Commands.hs:4002` (`joinContact`, relay group), `:4331`; `Subscriber.hs:505` group case, `:636`, `:829`, `:971`, `:1253`, and `membershipHandshakeProfile :: User -> GroupInfo -> CM Profile` (`Internal.hs`) for `:791`, `:843`, `:3371` and `addRelays`; `Internal.hs:2546` (`encodeXGrpAcpt`) and `:2730`. +- `acceptGroupJoinRequestAsync` (`Internal.hs:1037`): the expected header is a new parameter, passed to `createJoiningMember` and `updateMemberProfile` — in `memberJoinRequestViaRelay`, `memberPresHeader` of the joining member's id and the key in `XMember` when the message signature is verified with it. The signature is verified once, for the roster key check and for the header. `Nothing` is passed to `createJoiningMember` in `acceptGroupJoinSendRejectAsync`. +- Host `INFO` with `XInfo` (`Subscriber.hs:858-865`): after `storeMemberKey`, the profile is stored by `processMemberProfileUpdate` with `signedMemberPresHeader`, under the member's key. +- The profile is also stored by `processMemberProfileUpdate` when the received proof is accepted and differs from the stored member proof in its header or its disclosed information, and when a profile without a proof is received for a member with a stored proof; proofs are compared by `sameBadgeProof`. +- The member event is sent when the profile content or the badge status of the member changes; a change of the proof alone sends no event. +- Introductions: + - In `memberInfo` (`Internal.hs:1344`) the stored proof (item 11) is included when `acceptedProof` holds under `memberPresHeader` of the member's id and stored key, in channels and in p2p groups. + - In `xGrpMemNew`, `xGrpMemIntro` and `xGrpMemFwd` (`Subscriber.hs:3213, 3296, 3347`) the header is built from the key stored for the member after the introduction: + - a roster member in `xGrpMemNew` and an existing member in `xGrpMemIntro`, in a channel: `memberPresHeader` of the stored key; + - a new member in `xGrpMemIntro` in a channel: `memberInfoPresHeader` of the introduction with the key removed for roles from member up; + - other introductions, in channels and in p2p groups: `memberInfoPresHeader` of the introduction, whose key is stored. +- Invitation via contact: + - `profile :: Maybe Profile` in `XGrpAcpt`, the optional JSON field `profile`. + - The invitee (`Commands.hs:2851`, `Subscriber.hs:2700`, both by `encodeXGrpAcpt`, `Internal.hs:2541-2549`) includes its group profile, with `groupPresHeader`; the message is encoded by `encodeConnInfoSigning`, signed when a signing is returned by `groupMsgSigning` and the maximum of the contact connection's `peerChatVRange` is at least `relayWebCapVersion`. + - `XGrpAcpt` with a badge in its profile is signed by `groupMsgSigning` (`Internal.hs:2360-2363`). + - `encodeConnInfoSigning :: PQSupport -> Maybe MsgSigning -> ChatMsgEvent e -> CM ByteString` — `encodeSignedConnInfo` with a signing, `encodeConnInfoPQ` without one; used by `encodeXGrpAcpt`, `xGrpMemFwd` and `allowAgentConnectionAsync`. + - The host (`Subscriber.hs:785-795`) stores the key, allows the connection, then stores the profile by `processMemberProfileUpdate` with the signed message. For an invitee whose contact is active, the profile row is kept by `contactUpdatableFromMember` and the proof is stored on the membership (item 11). + - The host replies with `XGrpMemInfo` and its group profile in place of `XOk` (`Subscriber.hs:791-793`); the badge is presented at every version, and the reply is signed by `allowAgentConnectionAsync` from `relayWebCapVersion`, as at `:843-845`. + +**10. Link data** + +- Invitation links (`APIAddContact`, `recreateConn`): the chat generates the root key pair and calls `prepareConnectionLink SCMInvitation`; the badge is presented with `PHLink` of `plpLinkKey`; then `createConnectionForLink` is called. +- Invitation updates (`updatePCCShortLinkData`, `APISetConnectionIncognito`): `linkPresHeader` of the stored invitation short link. +- Address (`APICreateMyAddress`): `linkPresHeader` of the key in the prepared `CSLContact`. +- Address updates (`setMyAddressData`): the key of the stored short link. For an address without a short link, the key is taken from `prepareConnShortLink` when a badge is presented, then `setConnShortLink` is called once. +- Receipts: + - `linkDataBadge` (`Internal.hs:2285`): the expected header is a parameter — `linkPresHeader` of the short link, for an invitation (`Commands.hs:4415`) and for an address (`:4469-4470`). For a rejected proof the display badge, `localBadge`, is cleared. + - `createPreparedContact` (`Commands.hs:2206`): `linkPresHeader` of the short link in `accLink`; `Nothing` without a short link. + - `updateContactFromLinkData` (`Internal.hs:1644`): the header of the address link is a parameter. + +**11. Member proofs** — `Badges.hs`, `Types.hs`, `Types/Preferences.hs`, `Store/Shared.hs`, `Store/Groups.hs`, `Store/Connections.hs`, `Internal.hs`, `Subscriber.hs` + +A member's accepted proof is stored on the membership, in `group_member_badge_proofs`, and forwarded in introductions. The badge in the profile row is kept for display: the profile row of a member linked to a contact is the contact's. + +Migration `M20260925_badge_bindings`, after the statement of item 6, SQLite: + +```sql +CREATE TABLE group_member_badge_proofs( + group_member_id INTEGER PRIMARY KEY REFERENCES group_members ON DELETE CASCADE, + badge_proof BLOB NOT NULL, + badge_pres_header BLOB NOT NULL, + badge_key_idx INTEGER NOT NULL, + badge_type TEXT NOT NULL, + badge_expiry TEXT NOT NULL, + badge_extra TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +``` + +Postgres: `BIGINT`, `BYTEA` and `TIMESTAMPTZ`. Both schema dumps and `chat_query_plans.txt` are updated. + +- One row per member, keyed by the member id, as `rcv_files` is keyed by the file id. +- `MaybeBadgeProofRow` and `maybeRowToBadgeProof` in `Badges.hs`: the six proof columns of a `LEFT JOIN`. +- `PrefsJSON` is generalised: + + ```haskell + newtype NoJSON a = NoJSON {unNoJSON :: Maybe a} + + type PrefsJSON = NoJSON Object + ``` + + The field is omitted by `ToJSON`; `NoJSON Nothing` is returned by `FromJSON`. `NoJSON` is used at the construction sites. +- `GroupMember` gains `memberBadgeProof :: NoJSON BadgeProof`. It is removed from the bot API docs: `removeField "memberBadgeProof" $ sti @GroupMember`. +- Reads: + - The proof columns are selected by `groupMemberQuery` after the connection columns, with `LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id`; the field is set by `toContactMember`. + - They are selected the same way by the member read of `getConnectionEntity` (`Connections.hs:145-194`); the field is set by `toGroupAndMember`. + - `NoJSON Nothing` is set by `toGroupMember`, for the membership, chat item members and quoted members. +- Writes, for a received profile: + - a badge accepted by `acceptedProof` under the expected header, different from the stored proof, and verified by `verifyBadge` is upserted by `createMemberBadgeProof` (`INSERT ... ON CONFLICT (group_member_id) DO UPDATE`); a badge that does not verify keeps the row; + - for a profile without a badge and a member with a stored proof, the row is deleted; + - for a badge not accepted, or equal to the stored proof by `sameBadgeProof`, the row is kept. +- These rules are applied by `setMemberBadgeProof :: DB.Connection -> StoreCxt -> GroupMember -> Maybe ProofPresHeader -> Profile -> IO GroupMember`, and the member with the field set is returned. Used by `updateMemberProfile` and `updateContactMemberProfile`, and by `processMemberProfileUpdate` when `contactUpdatableFromMember` is false. +- The received profile is passed to `updateMemberProfile` after `redactedMemberProfile`, and unredacted to `updateContactMemberProfile` and `setMemberBadgeProof`. The profile with the stored proof in place of a rejected one is used for the comparisons, the business chat profile and the chat item. +- At creation the accepted proof is inserted when its verification succeeded (`verifiedProof`): `createNewMember_` (for `createNewGroupMember` and `createIntroReMember`), `createJoiningMember`, and the owner in `createRelayRequestGroup`. +- `memberBadgeProof`, or the profile's proof when it is absent, is included by `memberInfo`, filtered by `acceptedProof` under `memberPresHeader g memberId memberPubKey`. + +**12. The channel owner at the relay** — `Types.hs`, `Commands.hs`, `Subscriber.hs`, `Internal.hs`, `Store/Groups.hs`, `Store/RelayRequests.hs` + +- `GroupRelayInvitation` gains `publicGroupId :: Maybe B64UrlByteString` and `fromMemberKey :: Maybe MemberKey`, the optional JSON fields `publicGroupId` and `fromMemberKey`. The owner sets them in `addRelays` from `publicGroup' gInfo` and from `groupMemberKey` of the group keys; `addRelays` takes `GroupInfoKeys`. +- The invitation is always signed: `XGrpRelayInv_` is in `requiresSignature`, and the invitation is encoded by `encodeConnInfoSigning` with `groupMsgSigning`. Released relays parse the signed message and ignore the signature. +- `relayInvPresHeader :: Maybe SignedMsg -> GroupRelayInvitation -> Maybe ProofPresHeader` (`Subscriber.hs`) — `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, memberKey)))` with the owner's member id and key, when the signature is verified with that key under `smpEncode (publicGroupId, memberId)`; `Nothing` without both claims or without the signature. The signature is verified by `verifyMemberSig`, which `verifyGroupSig` uses with `groupBindingData`. +- It is computed in `xGrpRelayInv` and passed to `createRelayRequestGroup` as the expected header, there and through `rejectRelayInvitationAsync`. The owner's profile and membership proof are stored under it. +- Chat version 22, `signedRelayInvVersion`; `currentChatVersion` is 22. From this version of the owner's range, an invitation without both claims and a verified signature is ignored by `xGrpRelayInv` with a message error, and no request is created. An invitation from an owner below this version is accepted unsigned. +- The group id claim is written to `groups.relay_request_public_group_id` with the other relay request fields, and read by `getRelayRequestData` into `RelayRequestData`, which gains `reqPublicGroupId :: Maybe B64UrlByteString`. `group_profiles.public_group_id` holds only ids taken from link data. +- Migration `M20260925_badge_bindings`, SQLite and Postgres: `ALTER TABLE groups ADD COLUMN relay_request_public_group_id BLOB;`, `BYTEA` on Postgres. +- The key claim is written to the owner's `group_members.member_pub_key`. +- The owner member is read by `processRelayRequest` in the transaction that reads the group, and passed to `getLinkDataCreateRelayLink` and `acceptOwnerConnection`. +- The request is failed by `getLinkDataCreateRelayLink`: + - when the group id claim differs from the link's entity id; + - when the key claim differs from the key of the link data owner with the owner's member id. +- Each comparison is skipped for a request without that claim. +- The public group id and the owner keys are written from the link data by `updateRelayGroupKeys`. + +**13. Tests** — `ChatTests/Profiles.hs`, `ChatTests/ChatRelays.hs`, `ChatTests/Groups.hs` + +- Direct: the badge is shown after a request to an address with and without ratchet keys, after accepting, after joining a one-time link, after a retried join, and after a profile update. +- A proof bound to another chat is ignored, and the stored badge is kept. +- P2p group: the joiner's badge is shown at the host at the request and after `INFO`; an introduced member's badge is shown from the introduction; the badge is shown on both sides of an invitation via contact. +- Invitation via contact: the invitee's badge is stored on the membership at the host, and a later member receives it in the introduction. +- Channel: the owner's badge is shown at a subscriber and the subscriber's badge at the owner, forwarded by the relay (`testChannelMemberBadges`). +- Channel: a relay invitation with an owner key pair that differs from the link data is failed by the relay, and the relay stays invited (`testChannelAddRelayOwnerKeyMismatch`). +- Link data: the badge is shown from an invitation link under `PHLink`, an address, and an address that gets its first short link. +- Member proofs: the stored proof is deleted when a profile without a badge is received, for a member joined by a group link and for a member with a contact. +- Peers below `relayWebCapVersion`: `XGrpAcpt` to an inviting host and `XGrpMemInfo` to a joining member include the badge unsigned, the handshakes complete, and neither receiver stores the proof. + +The profile update and app changes are not part of this plan; they were merged in #7663. + +`PHTest` proofs are generated only by released clients. For a connection prepared by a released client and joined after the update, the badge is presented only when its ratchet was stored by an earlier attempt; a retried request to an address prepared by a released client is sent without a badge. + ## Out of scope -- Moving the direct chat profile proof to `PHChat`. - Requiring an expiration in the description proof, once servers grant one. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d6c932df39..2e0ecfead3 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e24d537eaaa32230eec1fefcd957d474d7b8d4d5" = "1ximwqi1jaiq1ml05y0mcsrgvabhp5is1ry2h91w5jjarjb1a5fj"; + "https://github.com/simplex-chat/simplexmq.git"."ee66dd68d4429e769da7f2506a48af5887e0f7af" = "0k58ayni6d36fjcahj8lvwkmg6yabmv60g8va6hh9z2ph09lhj2i"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."2330df3dc8c4b660674d3c02d76bb86f66c2abee" = "11pfj1vqsjdl26nplkr2l6z1xj7cn153qijj5gknmlzq3krn7x1i"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index daa16691e6..cb0e3387ed 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -168,6 +168,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json + Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings else exposed-modules: Simplex.Chat.Archive @@ -345,6 +346,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json + Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Badges.hs b/src/Simplex/Chat/Badges.hs index f34ba8ef93..437b55e24d 100644 --- a/src/Simplex/Chat/Badges.hs +++ b/src/Simplex/Chat/Badges.hs @@ -47,13 +47,18 @@ module Simplex.Chat.Badges generateBadgeProof, badgeProof, verifyBadge, - verifyBadge_, + acceptedProof, + acceptedBadge, + sameBadgeProof, + unboundProof, mkBadgeStatus, BadgeRow, BadgeProofKind (..), BadgeProofRow, badgeProofToRow, rowToBadgeProof, + MaybeBadgeProofRow, + maybeRowToBadgeProof, badgeToRow, localBadgeToRow, rowToBadge, @@ -76,7 +81,7 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, nominalDay) import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime) import Simplex.FileTransfer.Description (gb, maxFileSize) -import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..), fromTextField_) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..), blobFieldDecoder, fromTextField_) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS import Simplex.Messaging.Crypto.Entitlement (Entitlement (Entitlement), EntitlementCredential (EntitlementCredential), MasterKey (MasterKey), entitlementBBSHeader) @@ -267,10 +272,17 @@ maxSndXFTPFileSize lims now = \case _ -> noBadge lims -- Presentation header: a tag char + payload. PHTest is unbound - a fresh random nonce per --- presentation, not bound to any context; the 'T' tag marks it so master rejects it. +-- presentation, not bound to any context. -- PHUnknown is the forward-compat catch-all for tags this version does not interpret. -data ProofPresHeaderTag = PHTestTag | PHChatTag | PHFileInvTag | PHFileDescrTag | PHUnknownTag Char +data ProofPresHeaderTag + = PHTestTag -- random nonce: released clients + | PHChatTag -- direct chat ratchet, group member key + | PHFileInvTag -- file invitation + | PHFileDescrTag -- file description + | PHRequestTag -- request to address without ratchet keys + | PHLinkTag -- invitation and address link data + | PHUnknownTag Char -- tags of newer versions instance StrEncoding ProofPresHeaderTag where strEncode = B.singleton . \case @@ -278,6 +290,8 @@ instance StrEncoding ProofPresHeaderTag where PHChatTag -> 'C' PHFileInvTag -> 'F' PHFileDescrTag -> 'D' + PHRequestTag -> 'R' + PHLinkTag -> 'L' PHUnknownTag c -> c strP = tag <$> A.anyChar where @@ -286,6 +300,8 @@ instance StrEncoding ProofPresHeaderTag where 'C' -> PHChatTag 'F' -> PHFileInvTag 'D' -> PHFileDescrTag + 'R' -> PHRequestTag + 'L' -> PHLinkTag c -> PHUnknownTag c data ProofPresHeader @@ -293,6 +309,8 @@ data ProofPresHeader | PHChat ByteString | PHFileInv {chatBinding :: ByteString, fileSize :: Int64} | PHFileDescr {chatBinding :: ByteString, fileSize :: Int64, descrHash :: ByteString, fileExpires :: Maybe UTCTime} + | PHRequest ByteString + | PHLink ByteString | PHUnknown Char ByteString deriving (Eq, Show) @@ -311,6 +329,8 @@ instance StrEncoding ProofPresHeader where strEncode PHFileInvTag <> smpEncode (chatBinding, fileSize) PHFileDescr {chatBinding, fileSize, descrHash, fileExpires} -> strEncode PHFileDescrTag <> smpEncode (chatBinding, fileSize, descrHash, utcToSystemTime <$> fileExpires) + PHRequest code -> strEncode PHRequestTag <> code + PHLink linkKey -> strEncode PHLinkTag <> linkKey PHUnknown c b -> strEncode (PHUnknownTag c) <> b strP = strP >>= \case @@ -322,16 +342,25 @@ instance StrEncoding ProofPresHeader where PHFileDescrTag -> do (chatBinding, fileSize, descrHash, expires_) <- smpP pure PHFileDescr {chatBinding, fileSize, descrHash, fileExpires = systemToUTCTime <$> expires_} + PHRequestTag -> PHRequest <$> A.takeByteString + PHLinkTag -> PHLink <$> A.takeByteString PHUnknownTag c -> PHUnknown c <$> A.takeByteString --- v6.5.x accepts both; v7 will reject PHTest/PHUnknown -proofPresHeaderAccepted :: ProofPresHeader -> Bool -proofPresHeaderAccepted = \case - PHTest _ -> True - PHChat _ -> True - PHFileInv {} -> True - PHFileDescr {} -> True - PHUnknown _ _ -> True +acceptedProof :: Maybe ProofPresHeader -> BadgeProof -> Bool +acceptedProof expected_ b@BadgeProof {presHeader = BBSPresHeader ph} = unboundProof b || any ((ph ==) . strEncode) expected_ + +acceptedBadge :: Maybe ProofPresHeader -> Maybe BadgeProof -> Maybe BadgeProof -> Maybe BadgeProof +acceptedBadge expected_ stored_ received_ = if all (acceptedProof expected_) received_ then received_ else stored_ + +sameBadgeProof :: Maybe BadgeProof -> Maybe BadgeProof -> Bool +sameBadgeProof b_ b_' = (proofContent <$> b_) == (proofContent <$> b_') + where + proofContent b@BadgeProof {presHeader, badgeInfo} = (if unboundProof b then Nothing else Just presHeader, badgeInfo) + +unboundProof :: BadgeProof -> Bool +unboundProof BadgeProof {presHeader = BBSPresHeader ph} = case strDecode ph of + Right (PHTest _) -> True + _ -> False -- Payment proof @@ -425,16 +454,15 @@ verifyBadge keys b@(BadgeProof keyIdx _ _ _) = case M.lookup keyIdx keys of Just pk -> Just <$> verifyBadgeWith pk b verifyBadgeWith :: BBSPublicKey -> BadgeProof -> IO Bool -verifyBadgeWith pk (BadgeProof _ ph@(BBSPresHeader phBytes) proof badgeInfo) - | either (const False) proofPresHeaderAccepted (strDecode phBytes) = - bbsProofVerify pk proof bbsBadgeHeader ph bbsBadgeDisclosedIndexes bbsBadgeMessageCount (badgeInfoMessages badgeInfo) - | otherwise = pure False - -verifyBadge_ :: Map Int BBSPublicKey -> Maybe BadgeProof -> IO (Maybe Bool) -verifyBadge_ keys = maybe (pure (Just False)) (verifyBadge keys) +verifyBadgeWith pk (BadgeProof _ ph proof badgeInfo) = + bbsProofVerify pk proof bbsBadgeHeader ph bbsBadgeDisclosedIndexes bbsBadgeMessageCount (badgeInfoMessages badgeInfo) -- DB +instance FromField ProofPresHeader where fromField = blobFieldDecoder strDecode + +instance ToField ProofPresHeader where toField = toField . Binary . strEncode + instance FromField BadgeType where fromField = fromTextField_ textDecode instance ToField BadgeType where toField = toField . textEncode @@ -459,6 +487,12 @@ rowToBadgeProof (Binary p, Binary ph, idx, type_, badgeExpiry, badgeExtra) = do badgeType <- textDecode type_ pure $ BadgeProof idx (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra} +type MaybeBadgeProofRow = (Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe Int, Maybe Text, Maybe UTCTime, Maybe Text) + +maybeRowToBadgeProof :: MaybeBadgeProofRow -> Maybe BadgeProof +maybeRowToBadgeProof (p_, ph_, idx_, type_, expiry_, extra_) = + rowToBadgeProof =<< (,,,,,) <$> p_ <*> ph_ <*> idx_ <*> type_ <*> expiry_ <*> extra_ + -- (proof, pres_header, expiry, type, verified, extra, master_key, signature, key_idx) - binary columns wrapped in Binary (BLOB/bytea) type BadgeRow = (Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe UTCTime, Maybe Text, Maybe BoolInt, Maybe Text, Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe Int) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4ffd27f428..f1b2ad3f7a 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -59,7 +59,7 @@ import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber import Crypto.Random (ChaChaDRG) import Simplex.Messaging.Session (SessionVar (..), withGetSessVar') -import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType, LocalBadge (..), badgeServerCredential, mkBadgeStatus, maxSndXFTPFileSize, verifyCredential) +import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType, LocalBadge (..), ProofPresHeader (..), badgeServerCredential, mkBadgeStatus, maxSndXFTPFileSize, verifyCredential) import qualified Simplex.Chat.Badges.Ledger as L import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIssueError (..), BadgeIssueFailure (..), BadgeState (..)) import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) @@ -2124,11 +2124,12 @@ processChatCommand cxt nm = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - -- TODO [badges] bind link and badge to handshake context - linkProfile <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True + rootKey <- atomically . C.generateKeyPair =<< asks random + (preparedLink, preparedParams@PreparedLinkParams {plpLinkKey = LinkKey linkKey}) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) SCMInvitation rootKey Nothing False Nothing IKUsePQ False Nothing + linkProfile <- presentUserBadge user incognitoProfile (Just $ PHLink linkKey) $ userProfileDirect user incognitoProfile Nothing True let userData = contactShortLinkData linkProfile {contactDomain = Nothing} Nothing userLinkData = UserInvLinkData userData - (connId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation (Just userLinkData) Nothing IKUsePQ True subMode + (connId, ccLink) <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True preparedLink preparedParams userLinkData 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 @@ -2137,7 +2138,7 @@ processChatCommand cxt nm = \case processChatCommand cxt nm $ APIAddContact userId incognito APISetConnectionIncognito connId incognito -> withUser $ \user@User {userId} -> do conn <- withFastStore $ \db -> getPendingContactConnection db userId connId - let PendingContactConnection {pccConnStatus, customUserProfileId} = conn + let PendingContactConnection {pccConnStatus, customUserProfileId, connLinkInv} = conn case (pccConnStatus, customUserProfileId, incognito) of (ConnNew, Nothing, True) -> do incognitoProfile <- liftIO generateRandomProfile @@ -2147,7 +2148,7 @@ processChatCommand cxt nm = \case updatePCCIncognito db user conn (Just pId) sLnk pure $ CRConnectionIncognitoUpdated user conn' (Just incognitoProfile) (ConnNew, Just pId, False) -> do - sLnk <- updatePCCShortLinkData conn =<< presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) + sLnk <- updatePCCShortLinkData conn =<< presentUserBadge user Nothing (linkPresHeader <$> (connShortLink' =<< connLinkInv)) (userProfileDirect user Nothing Nothing True) conn' <- withFastStore' $ \db -> do deletePCCIncognitoProfile db user pId updatePCCIncognito db user conn Nothing sLnk @@ -2166,11 +2167,14 @@ processChatCommand cxt nm = \case recreateConn user conn@PendingContactConnection {customUserProfileId, connLinkInv} newUser = do subMode <- chatReadVar subscriptionMode let short = isJust $ connShortLink' =<< connLinkInv - userLinkData_ <- + (agConnId, ccLink) <- if short - then Just . UserInvLinkData . (`contactShortLinkData` Nothing) <$> presentUserBadge newUser Nothing (userProfileDirect newUser Nothing Nothing True) - else pure Nothing - (agConnId, ccLink) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation userLinkData_ Nothing IKPQOn True subMode + then do + rootKey <- atomically . C.generateKeyPair =<< asks random + (preparedLink, preparedParams@PreparedLinkParams {plpLinkKey = LinkKey linkKey}) <- withAgent $ \a -> prepareConnectionLink a (aUserId newUser) SCMInvitation rootKey Nothing False Nothing IKPQOn False Nothing + userLinkData <- UserInvLinkData . (`contactShortLinkData` Nothing) <$> presentUserBadge newUser Nothing (Just $ PHLink linkKey) (userProfileDirect newUser Nothing Nothing True) + withAgent $ \a -> createConnectionForLink a nm (aUserId newUser) True preparedLink preparedParams userLinkData subMode + else withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation Nothing Nothing IKPQOn True subMode ccLink' <- shortenCreatedLink ccLink conn' <- withFastStore' $ \db -> do deleteConnectionRecord db user connId @@ -2205,8 +2209,8 @@ processChatCommand cxt nm = \case Just (AChatItem SCTGroup dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTGroup chat - ACCL _ (CCLink cReq _) -> do - ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId (True <$ verifiedDomain) + ACCL _ (CCLink cReq sLnk) -> do + ct <- withStore $ \db -> createPreparedContact db cxt user (linkPresHeader <$> sLnk) profile accLink welcomeSharedMsgId (True <$ verifiedDomain) void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDDirectRcv ct createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing Nothing @@ -2468,15 +2472,15 @@ processChatCommand cxt nm = \case Just True -> (IKUsePQ, True) Just False -> (IKPQOn, True) Nothing -> (IKPQOn, False) - (ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) rootKey entityId True Nothing pqInitKeys useDR server_ + (ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) SCMContact rootKey (Just entityId) True Nothing pqInitKeys useDR server_ ccLink' <- shortenCreatedLink ccLink -- TODO [relays] relay: add identity, key to link data? userData <- if isTrue userChatRelay then pure $ relayShortLinkData (userProfileDirect user Nothing Nothing True) - else (`contactShortLinkData` Nothing) <$> presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) + else (`contactShortLinkData` Nothing) <$> presentUserBadge user Nothing (linkPresHeader <$> connShortLink' ccLink) (userProfileDirect user Nothing Nothing True) let userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData, ratchetKeys = Nothing} - connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode + (connId, _) <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode let ccLink'' = if isTrue userChatRelay then setShortLinkType CCTRelay ccLink' else ccLink' withFastStore $ \db -> createUserContactLink db user connId ccLink'' subMode rootPrivKey pure $ CRUserContactLinkCreated user ccLink'' @@ -2745,7 +2749,7 @@ processChatCommand cxt nm = \case let entityId = C.sha256Hash $ C.pubKeyBytes rootPubKey crClientData = encodeJSON $ CRDataGroup groupLinkId -- prepare link with entityId as linkEntityId (no server request) - (ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) rootKey entityId True (Just crClientData) IKPQOff False Nothing + (ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) SCMContact rootKey (Just entityId) True (Just crClientData) IKPQOff False Nothing ccLink' <- setShortLinkType CCTChannel <$> shortenCreatedLink ccLink sLnk <- case connShortLink' ccLink' of Just sl -> pure sl @@ -2758,14 +2762,14 @@ processChatCommand cxt nm = \case userData = encodeShortLinkData $ GroupShortLinkData {groupProfile = groupProfile', publicGroupData = Just (PublicGroupData 1)} userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData, ratchetKeys = Nothing} -- create connection with prepared link (single network call) - connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode + (connId, _) <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode let groupKeys = GKPublicGroup {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} setupLink gInfo = do -- TODO [relays] starting role should be communicated in protocol from owner to relays subRole <- asks $ channelSubscriberRole . config gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId subRole subMode relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds) - results <- addRelays user gInfo sLnk relays + results <- addRelays user (GIK gInfo groupKeys) sLnk relays pure (gLink, results) pure (groupProfile', memberId, groupKeys, setupLink) NewPublicGroup incognito relayIds gProfile -> withUser $ \User {userId} -> @@ -2777,10 +2781,10 @@ processChatCommand cxt nm = \case pure (gInfo, relays) pure $ CRGroupRelays user gInfo relays APIAddGroupRelays groupId relayIds -> withUser $ \user -> withGroupLock "addGroupRelays" groupId $ do - (gInfo, existingRelays) <- withFastStore $ \db -> do - gi <- getGroupInfo db cxt user groupId + (g@(GIK gInfo _), existingRelays) <- withFastStore $ \db -> do + g@(GIK gi _) <- getGroupInfoKeys db cxt user groupId rs <- liftIO $ getGroupRelays db gi - pure (gi, rs) + pure (g, rs) assertUserGroupRole gInfo GROwner unless (useRelays' gInfo) $ throwCmdError "group does not use relays" let existingRelayIds = map (\GroupRelay {userChatRelay = UserChatRelay {chatRelayId = DBEntityId rId}} -> rId) existingRelays @@ -2790,7 +2794,7 @@ processChatCommand cxt nm = \case Just sl -> pure sl Nothing -> throwChatError $ CEException "group link has no short link" relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds) - results <- addRelays user gInfo sLnk relays + results <- addRelays user g sLnk relays case partitionEithers (map snd results) of ([], _) -> do relays' <- withFastStore $ \db -> liftIO $ getGroupRelays db gInfo @@ -2843,12 +2847,11 @@ processChatCommand cxt nm = \case inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId (inv,) <$> getContactViaMember db cxt user fromMember let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}, groupKeys = gks} = invitation - GroupMember {memberId = membershipMemId} = membership Contact {activeConn} = ct case activeConn of Just Connection {peerChatVRange} -> do subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XGrpAcpt membershipMemId (Just $ groupMemberKey gks) + dm <- encodeXGrpAcpt user (GIK g gks) peerChatVRange agentConnId <- case memberConn fromMember of Nothing -> do (agentConnId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connRequest PQSupportOff @@ -3401,21 +3404,21 @@ processChatCommand cxt nm = \case -- so incognito profile can be attached to it and be visible in UI before accepting Nothing -> joinNewConn subMode Just conn@Connection {connStatus} -> case connStatus of - ConnPrepared -> joinPreparedConn subMode conn + ConnPrepared -> joinPreparedConn subMode conn $ connPresHeader conn _ -> throwChatError $ CEException "connection already started (past prepared status)" where joinNewConn subMode = do -- possible improvement: use agent connRequestAgentVersion to determine pqSupport here; -- for joinPreparedConn below - same + encodeConnInfoPQ; -- same for auto-accept on xGrpDirectInv - (acId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + (acId, binding) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff conn <- withStore $ \db -> do connId <- liftIO $ createMemberContactConn db user acId Nothing gInfo mConn ConnPrepared contactId subMode getConnectionById db cxt user connId - joinPreparedConn subMode conn - joinPreparedConn subMode conn = do + joinPreparedConn subMode conn $ pure $ Just $ directPresHeader binding + joinPreparedConn subMode conn getPresHeader = do -- [incognito] send membership incognito profile - p <- presentUserBadge user (incognitoMembershipProfile gInfo) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True + p <- presentUserBadgeWith user (incognitoMembershipProfile gInfo) getPresHeader $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True dm <- encodeConnInfo $ XInfo p Nothing sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined @@ -3821,7 +3824,7 @@ processChatCommand cxt nm = \case | connStatus == ConnNew && contactConnInitiated -> joinNewConn chatV -- own connection link | connStatus == ConnPrepared -> do -- retrying join after error localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId - joinPreparedConn conn (fromLocalProfile <$> localIncognitoProfile) + joinPreparedConn conn (fromLocalProfile <$> localIncognitoProfile) $ connPresHeader conn Just ent -> throwCmdError $ "connection is not RcvDirectMsgConnection: " <> show (connEntityInfo ent) where -- all supported versions support PQ encryption @@ -3829,12 +3832,12 @@ processChatCommand cxt nm = \case joinNewConn chatV = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - (connId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup' + (connId, binding) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup' let ccLink = CCLink cReq $ serverShortLink <$> sLnk_ conn <- withFastStore' $ \db -> createDirectConnection' db userId connId ccLink contactId_ ConnPrepared incognitoProfile subMode chatV pqSup' - joinPreparedConn conn incognitoProfile - joinPreparedConn conn incognitoProfile = do - profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True + joinPreparedConn conn incognitoProfile $ pure $ Just $ directPresHeader binding + joinPreparedConn conn incognitoProfile getPresHeader = do + profileToSend <- presentUserBadgeWith user incognitoProfile getPresHeader $ userProfileDirect user incognitoProfile Nothing True dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend Nothing sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup' subMode let newStatus = if sqSecured then ConnSndReady else ConnJoined @@ -3888,14 +3891,14 @@ processChatCommand cxt nm = \case when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" -- TODO [relays] member: refactor joinContact and up avoiding parallel ifs, xContactId is not used xContactId <- mkXContactId xContactId_ - (cReq', localIncognitoProfile) <- withFastStore $ \db -> (,) <$> getConnReqContact db connId <*> forM customUserProfileId (getProfileById db userId) + ((cReq', reqHeader_), localIncognitoProfile) <- withFastStore $ \db -> (,) <$> getConnReqContact db connId <*> forM customUserProfileId (getProfileById db userId) let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - conn' <- joinContact user conn cReq' incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ PQSupportOn + conn' <- joinContact user conn cReq' incognitoProfile reqHeader_ xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ PQSupportOn pure $ CVRSentInvitation conn' incognitoProfile connect' groupLinkId xContactId_ gInfo_ = do let inGroup = isJust groupLinkId pqSup = if inGroup then PQSupportOff else PQSupportOn - (connId, chatV) <- prepareContact user cReq pqSup + (connId, chatV, reqHeader) <- prepareContact user cReq pqSup xContactId <- mkXContactId xContactId_ -- [incognito] generate profile to send, or use membership profile for relay groups incognitoProfile_ <- case gInfo_ of @@ -3904,8 +3907,8 @@ processChatCommand cxt nm = \case let incognitoProfile = fromIncognitoProfile <$> incognitoProfile_ 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_ gInfo_ relayMemberId_ pqSup + conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq reqHeader cReqHash1 sLnk' xContactId incognitoProfile_ groupLinkId subMode chatV pqSup + conn' <- joinContact user conn cReq incognitoProfile (Just reqHeader) xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup pure $ CVRSentInvitation conn' incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = @@ -3913,23 +3916,23 @@ processChatCommand cxt nm = \case case activeConn of Nothing -> do let pqSup = PQSupportOn - (connId, chatV) <- prepareContact user cReq pqSup + (connId, chatV, reqHeader) <- prepareContact user cReq pqSup newXContactId <- XContactId <$> drgRandomBytes 16 -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode let cReqHash = contactCReqHash cReq - conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup - void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing Nothing pqSup + conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq reqHeader cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup + void $ joinContact user conn cReq incognitoProfile (Just reqHeader) newXContactId Nothing Nothing Nothing Nothing pqSup ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile Just conn@Connection {connId, connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of ConnPrepared -> do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" xContactId <- mkXContactId xContactId_ - (cReq', localIncognitoProfile) <- withFastStore $ \db -> (,) <$> getConnReqContact db connId <*> forM customUserProfileId (getProfileById db userId) + ((cReq', reqHeader_), localIncognitoProfile) <- withFastStore $ \db -> (,) <$> getConnReqContact db connId <*> forM customUserProfileId (getProfileById db userId) let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - void $ joinContact user conn cReq' incognitoProfile xContactId Nothing Nothing Nothing Nothing PQSupportOn + void $ joinContact user conn cReq' incognitoProfile reqHeader_ xContactId Nothing Nothing Nothing Nothing PQSupportOn ct' <- withStore $ \db -> getContact db cxt user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" @@ -3970,21 +3973,21 @@ processChatCommand cxt nm = \case deleteMemberConnection m deleteOrUpdateMemberRecord user gInfo m _ -> pure () - prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat) + prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat, ProofPresHeader) prepareContact user cReq pqSup = do lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> throwChatError CEInvalidConnReq Just _ -> do let chatV = initialChatVersion - (connId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup - pure (connId, chatV) + (connId, binding) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq pqSup + pure (connId, chatV, directPresHeader binding) mkXContactId :: Maybe XContactId -> CM XContactId mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure - joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfoKeys) -> Maybe MemberId -> PQSupport -> CM Connection - joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup = do + joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> Maybe ProofPresHeader -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfoKeys) -> Maybe MemberId -> PQSupport -> CM Connection + joinContact user conn cReq incognitoProfile reqHeader_ xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup = do -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile profileToSend <- - presentUserBadge user incognitoProfile $ case gInfo_ of + presentUserBadge user incognitoProfile presHeader_ $ case gInfo_ of Just gInfo_' -> userProfileInGroup' user ((\(GIK g _) -> g) <$> gInfo_') incognitoProfile Nothing -> userProfileDirect user incognitoProfile Nothing True dm <- case gInfo_ of @@ -3998,6 +4001,10 @@ processChatCommand cxt nm = \case subMode <- chatReadVar subscriptionMode void $ withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup subMode withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared ConnJoined + where + presHeader_ = case gInfo_ of + Just (Just (GIK g _)) | useRelays' g -> groupPresHeader g + _ -> reqHeader_ contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> @@ -4052,7 +4059,8 @@ processChatCommand cxt nm = \case case changedCts_ of Nothing -> pure $ UserProfileUpdateSummary 0 0 [] Just changedCts -> do - idsEvts <- mapM ctSndEvent changedCts + presHeaders <- if presentsUserBadge user' then connsPresHeaders $ map (\ChangedProfileContact {conn} -> conn) $ L.toList changedCts else pure M.empty + idsEvts <- mapM (ctSndEvent presHeaders) changedCts msgReqs_ <- lift $ L.zipWith ctMsgReq changedCts <$> createSndMessages idsEvts (errs, cts) <- partitionEithers . L.toList . L.zipWith (second . const) changedCts <$> deliverMessagesB msgReqs_ unless (null errs) $ toView $ CEvtChatErrors errs @@ -4077,18 +4085,19 @@ processChatCommand cxt nm = \case ct' = updateMergedPreferences user' ct mergedProfile' = userProfileDirect user' Nothing (Just ct') False -- non-incognito (filtered above), so the user's badge is presented; a profile update keeps the badge instead of clearing it - ctSndEvent :: ChangedProfileContact -> CM (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) - ctSndEvent ChangedProfileContact {mergedProfile', conn = Connection {connId}} = do - p'' <- presentUserBadge user' Nothing mergedProfile' + ctSndEvent :: Map ConnId ProofPresHeader -> ChangedProfileContact -> CM (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json) + ctSndEvent presHeaders ChangedProfileContact {mergedProfile', conn = conn@Connection {connId}} = do + p'' <- presentUserBadge user' Nothing (M.lookup (aConnId conn) presHeaders) mergedProfile' pure (ConnectionId connId, Nothing, XInfo p'' Nothing) ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq ctMsgReq ChangedProfileContact {conn} = fmap $ \SndMessage {msgId, msgBody} -> (conn, MsgFlags {notification = hasNotification XInfo_}, (vrValue msgBody, [msgId])) setMyAddressData :: Bool -> Maybe InitialKeys -> User -> UserContactLink -> CM UserContactLink - setMyAddressData rotateKeys pqInitKeys user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink _, addressSettings} = do + setMyAddressData rotateKeys pqInitKeys user@User {userChatRelay} ucl@UserContactLink {userContactLinkId, connLinkContact = CCLink connFullLink sLnk_, addressSettings} = do conn <- withFastStore $ \db -> getUserAddressConnection db cxt user - shortLinkProfile <- presentUserBadge user Nothing (userProfileDirect user Nothing Nothing True) + let getPresHeader = Just . linkPresHeader <$> maybe (withAgent $ \a -> prepareConnShortLink a (aConnId conn) Nothing) pure sLnk_ + shortLinkProfile <- presentUserBadgeWith user Nothing getPresHeader (userProfileDirect user Nothing Nothing True) -- TODO [short links] do not save address to server if data did not change, spinners, error handling let userData | isTrue userChatRelay = relayShortLinkData shortLinkProfile @@ -4102,7 +4111,7 @@ processChatCommand cxt nm = \case pure ucl' updateContactPrefs :: User -> Contact -> Preferences -> CM ChatResponse updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct - updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' + updateContactPrefs user@User {userId} ct@Contact {activeConn = Just conn@Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' | contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated user ct ct | otherwise = do assertDirectAllowed user MDSnd ct XInfo_ @@ -4112,7 +4121,7 @@ processChatCommand cxt nm = \case mergedProfile' = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateContactPrefs" (contactId' ct) $ do - p <- presentUserBadge user incognitoProfile mergedProfile' + p <- presentUserBadgeWith user incognitoProfile (connPresHeader conn) mergedProfile' void (sendDirectContactMessage user ct' $ XInfo p Nothing) `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' @@ -4311,8 +4320,8 @@ processChatCommand cxt nm = \case toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId' ci) - addRelays :: User -> GroupInfo -> ShortLinkContact -> [UserChatRelay] -> CM [(UserChatRelay, Either ChatError GroupRelay)] - addRelays user gInfo@GroupInfo {membership} groupSLink relays = + addRelays :: User -> GroupInfoKeys -> ShortLinkContact -> [UserChatRelay] -> CM [(UserChatRelay, Either ChatError GroupRelay)] + addRelays user g@(GIK gInfo@GroupInfo {membership} gks) groupSLink relays = mapConcurrently addRelay relays where addRelay :: UserChatRelay -> CM (UserChatRelay, Either ChatError GroupRelay) @@ -4332,14 +4341,17 @@ processChatCommand cxt nm = \case pure (relayMember, conn, groupRelay) let GroupMember {memberRole = userRole, memberId = userMemberId} = membership GroupMember {memberId = relayMemberId} = relayMember - membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership + membershipProfile <- membershipHandshakeProfile user gInfo let relayInv = GroupRelayInvitation { fromMember = MemberIdRole userMemberId userRole, fromMemberProfile = membershipProfile, relayMemberId, - groupLink = groupSLink + groupLink = groupSLink, + publicGroupId = (\PublicGroupProfile {publicGroupId = gId} -> gId) <$> publicGroup' gInfo, + fromMemberKey = Just $ groupMemberKey gks } - dm <- encodeConnInfo $ XGrpRelayInv relayInv + msg = XGrpRelayInv relayInv + dm <- encodeConnInfoSigning PQSupportOff (groupMsgSigning False g msg) msg sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode let newConnStatus = if sqSecured then ConnSndReady else ConnJoined withFastStore' $ \db -> do @@ -4417,7 +4429,7 @@ processChatCommand cxt nm = \case Just (createdLink, p) -> pure (createdLink, Nothing, Nothing, p) Nothing -> do (FixedLinkData {rootKey}, cData, cReq) <- getShortLinkConnReq nm user l' - contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) + contactSLinkData_ <- mapM (linkDataBadge $ linkPresHeader l') =<< liftIO (decodeLinkUserData cData) let ov = verifyLinkOwner rootKey [] l sig_ invitationReqAndPlan cReq (Just l') contactSLinkData_ ov where @@ -4471,12 +4483,13 @@ processChatCommand cxt nm = \case when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally l' <- resolveSLink (FixedLinkData {rootKey}, cData, cReq) <- getShortLinkConnReq nm user l' - contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) + let presHeader = linkPresHeader l' + contactSLinkData_ <- mapM (linkDataBadge presHeader) =<< liftIO (decodeLinkUserData cData) let linkProfile_ = (\ContactShortLinkData {profile} -> profile) <$> contactSLinkData_ linkDomain_ = linkProfile_ >>= \Profile {contactDomain} -> claimDomain <$> contactDomain planDomain = case nl of CTName ni -> Just (nameDomain ni); _ -> Nothing refreshContact ct' = case (planDomain, linkProfile_) of - (Just _, Just p) -> updateContactFromLinkData user ct' p + (Just _, Just p) -> updateContactFromLinkData user ct' presHeader p _ -> pure ct' forM_ planDomain $ \nameDomain -> unless (linkDomain_ == Just nameDomain) $ throwChatError $ CESimplexDomainNotReady nameDomain SDEUnknownDomain @@ -5223,14 +5236,12 @@ presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadg lift $ withAgent' $ \a -> setUserEntitlement a (aUserId user') (badgeServerCredential localBadge) cxt <- chatStoreCxt contacts <- withFastStore' $ \db -> getUserContacts db cxt user' - withChatLock "presentUserBadge" $ forM_ contacts $ \ct -> - case contactSendConn_ ct of - Right conn - | not (connIncognito conn) -> do - let ct' = updateMergedPreferences user' ct - p <- presentUserBadge user' Nothing $ userProfileDirect user' Nothing (Just ct') False - void (sendDirectContactMessage user' ct' (XInfo p Nothing)) `catchAllErrors` eToView - _ -> pure () + let sendConns = [(ct, conn) | ct <- contacts, Right conn <- [contactSendConn_ ct], not (connIncognito conn)] + presHeaders <- if presentsUserBadge user' then connsPresHeaders $ map snd sendConns else pure M.empty + withChatLock "presentUserBadge" $ forM_ sendConns $ \(ct, conn) -> do + let ct' = updateMergedPreferences user' ct + p <- presentUserBadge user' Nothing (M.lookup (aConnId conn) presHeaders) $ userProfileDirect user' Nothing (Just ct') False + void (sendDirectContactMessage user' ct' (XInfo p Nothing)) `catchAllErrors` eToView -- | The check character is verified before anything leaves the device, and the signing keys are -- stashed before the request is sent, so a retry reaches the service as the same signer. diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 4008bc2727..6ea428ac1f 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -53,7 +53,7 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time (addUTCTime) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds, secondsToDiffTime) -import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), ProofPresHeader (..), BadgeProof (..), BadgeProofKind (..), BadgeStatus (..), FileSizeLimits (..), LocalBadge (..), badgeProof, badgeSndGraceInterval, generateBadgeProof, localBadgeStatus, maxXFTPFileSize, mkBadgeStatus, verifyBadge) +import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), ProofPresHeader (..), BadgeProof (..), BadgeProofKind (..), BadgeStatus (..), FileSizeLimits (..), LocalBadge (..), acceptedProof, badgeSndGraceInterval, generateBadgeProof, localBadgeStatus, maxXFTPFileSize, mkBadgeStatus, verifyBadge) import Simplex.Chat.Names (SimplexDomainClaim (..), claimDomain) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -992,28 +992,28 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId pqSup' = pqSup `CR.pqSupportAnd` pqSupport cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange - (ct, conn, incognitoProfile) <- case contactId_ of + (ct, conn, incognitoProfile, getPresHeader) <- case contactId_ of Nothing -> do incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - (connId, _) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' + (connId, binding) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' (ct, conn) <- withStore' $ \db -> createContactFromRequest db user userContactLinkId_ connId chatV cReqChatVRange cName profileId cp xContactId incognitoProfile subMode pqSup' False - pure (ct, conn, incognitoProfile) + pure (ct, conn, incognitoProfile, pure $ Just $ directPresHeader binding) Just contactId -> do ct <- withFastStore $ \db -> getContact db cxt user contactId case contactConn ct of Nothing -> do incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - (connId, _) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' + (connId, binding) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) True invId pqSup' currentTs <- liftIO getCurrentTime conn <- withStore' $ \db -> do forM_ xContactId $ \xcId -> setContactAcceptedXContactId db ct xcId createAcceptedContactConn db user userContactLinkId_ contactId connId chatV cReqChatVRange pqSup' incognitoProfile subMode currentTs - pure (ct {activeConn = Just conn} :: Contact, conn, incognitoProfile) + pure (ct {activeConn = Just conn} :: Contact, conn, incognitoProfile, pure $ Just $ directPresHeader binding) Just conn@Connection {customUserProfileId} -> do incognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId - pure (ct, conn, ExistingIncognito <$> incognitoProfile) - profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True + pure (ct, conn, ExistingIncognito <$> incognitoProfile, connPresHeader conn) + profileToSend <- presentUserBadgeWith user incognitoProfile getPresHeader $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend Nothing (ct,conn,) <$> withAgent (\a -> acceptContact a nm (aUserId user) (aConnId conn) True invId dm pqSup' subMode) @@ -1025,10 +1025,10 @@ acceptContactRequestAsync UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId, pqSupport = cReqPQSup} incognitoProfile = do subMode <- chatReadVar subscriptionMode - profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange - (cmdId, acId) <- prepareAgentAccept user True cReqInvId cReqPQSup + ((cmdId, acId), presHeader) <- prepareAgentAccept user True cReqInvId cReqPQSup + profileToSend <- presentUserBadge user incognitoProfile (Just presHeader) $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True currentTs <- liftIO getCurrentTime ct' <- withStore $ \db -> do forM_ xContactId $ \xcId -> liftIO $ setContactAcceptedXContactId db ct xcId @@ -1038,7 +1038,7 @@ acceptContactRequestAsync agentAcceptContactAsync cmdId acId True cReqInvId (XInfo profileToSend Nothing) cReqPQSup subMode pure ct' -acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfoKeys -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember +acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfoKeys -> InvitationId -> VersionRangeChat -> Profile -> Maybe ProofPresHeader -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember acceptGroupJoinRequestAsync user@User {userId} uclId @@ -1046,6 +1046,7 @@ acceptGroupJoinRequestAsync cReqInvId cReqChatVRange cReqProfile + presHeader_ cReqXContactId_ cReqMemberId_ welcomeMsgId_ @@ -1064,10 +1065,10 @@ acceptGroupJoinRequestAsync -- refresh the hash placeholder name from the authenticated join profile; role + key stay roster-authoritative withStore $ \db -> do liftIO $ updateGroupMemberStatus db userId m initialStatus - void $ updateMemberProfile db cxt user m cReqProfile + void $ updateMemberProfile db cxt user m presHeader_ cReqProfile pure (groupMemberId' m, memberId' m) Nothing -> withStore $ \db -> - createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile presHeader_ cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_ let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -1085,7 +1086,7 @@ acceptGroupJoinRequestAsync } subMode <- chatReadVar subscriptionMode let chatV = vr cxt `peerConnChatVersion` cReqChatVRange - (cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff + ((cmdId, acId), _) <- prepareAgentAccept user True cReqInvId PQSupportOff m <- withStore $ \db -> do liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode getGroupMemberById db cxt user groupMemberId @@ -1105,7 +1106,7 @@ acceptGroupJoinSendRejectAsync gVar <- asks random cxt <- chatStoreCxt (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing + createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile Nothing cReqXContactId_ Nothing Nothing GRObserver GSMemRejected Nothing let GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = XGrpLinkReject $ @@ -1117,7 +1118,7 @@ acceptGroupJoinSendRejectAsync } subMode <- chatReadVar subscriptionMode let chatV = vr cxt `peerConnChatVersion` cReqChatVRange - (cmdId, acId) <- prepareAgentAccept user False cReqInvId PQSupportOff + ((cmdId, acId), _) <- prepareAgentAccept user False cReqInvId PQSupportOff m <- withStore $ \db -> do liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode getGroupMemberById db cxt user groupMemberId @@ -1152,7 +1153,7 @@ acceptBusinessJoinRequestAsync } subMode <- chatReadVar subscriptionMode let chatV = vr cxt `peerConnChatVersion` cReqChatVRange - (cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff + ((cmdId, acId), _) <- prepareAgentAccept user True cReqInvId PQSupportOff withStore' $ \db -> do forM_ xContactId $ \xcId -> setBusinessChatAcceptedXContactId db gInfo xcId createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode @@ -1179,7 +1180,7 @@ acceptRelayJoinRequestAsync subMode <- chatReadVar subscriptionMode cxt <- chatStoreCxt let chatV = vr cxt `peerConnChatVersion` cReqChatVRange - (cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff + ((cmdId, acId), _) <- prepareAgentAccept user True cReqInvId PQSupportOff r <- withStore $ \db -> do liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode gInfo' <- liftIO $ updateRelayOwnStatusFromTo db gInfo RSInvited RSAccepted @@ -1193,20 +1194,21 @@ rejectRelayInvitationAsync -> Int64 -> StoreCxt -> GroupRelayInvitation + -> Maybe ProofPresHeader -> InvitationId -> VersionRangeChat -> Int64 -> RelayRejectionReason -> CM () -rejectRelayInvitationAsync user uclId cxt groupRelayInv invId reqChatVRange initialDelay reason = do +rejectRelayInvitationAsync user uclId cxt groupRelayInv presHeader_ invId reqChatVRange initialDelay reason = do (_gInfo, ownerMember) <- withStore $ \db -> - createRelayRequestGroup db cxt user groupRelayInv invId reqChatVRange initialDelay GSMemInvited RSRejected + createRelayRequestGroup db cxt user groupRelayInv presHeader_ invId reqChatVRange initialDelay GSMemInvited RSRejected let GroupMember {groupMemberId} = ownerMember msg = XGrpRelayReject reason subMode <- chatReadVar subscriptionMode chatVR <- chatVersionRange let chatV = chatVR `peerConnChatVersion` reqChatVRange - (cmdId, acId) <- prepareAgentAccept user False invId PQSupportOff + ((cmdId, acId), _) <- prepareAgentAccept user False invId PQSupportOff withStore' $ \db -> createJoiningMemberConnection db user uclId (cmdId, acId) chatV reqChatVRange groupMemberId subMode agentAcceptContactAsync cmdId acId False invId msg PQSupportOff subMode @@ -1345,14 +1347,16 @@ userProfileInGroup' User {profile = p} mg incognitoProfile = in maybe p' (\g -> redactedMemberProfile g (membership g) p') mg memberInfo :: GroupInfo -> GroupMember -> MemberInfo -memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, activeConn} = +memberInfo g m@GroupMember {memberId, memberRole, memberProfile, memberPubKey, activeConn, memberBadgeProof} = MemberInfo { memberId, memberRole, v = ChatVersionRange . peerChatVRange <$> activeConn, - profile = redactedMemberProfile g m $ fromLocalProfile memberProfile, + profile = (p :: Profile) {badge = mfilter (acceptedProof $ memberPresHeader g memberId memberPubKey) (unNoJSON memberBadgeProof <|> badge)}, memberKey = MemberKey <$> memberPubKey } + where + p@Profile {badge} = redactedMemberProfile g m $ fromLocalProfile memberProfile redactedMemberProfile :: GroupInfo -> GroupMember -> Profile -> Profile redactedMemberProfile g m Profile {displayName, fullName, shortDescr, description, image, contactLink = lnk, peerType, badge, contactDomain} = @@ -1649,12 +1653,12 @@ updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = G newClaim = groupClaim groupProfile verifyResolved = isJust resolvedDomain_ && resolvedDomain_ == newClaim -updateContactFromLinkData :: User -> Contact -> Profile -> CM Contact -updateContactFromLinkData user ct@Contact {profile = profile@LocalProfile {contactDomain = prevClaim, contactDomainVerified}} linkProfile@Profile {contactDomain = newClaim} +updateContactFromLinkData :: User -> Contact -> ProofPresHeader -> Profile -> CM Contact +updateContactFromLinkData user ct@Contact {profile = profile@LocalProfile {contactDomain = prevClaim, contactDomainVerified}} presHeader linkProfile@Profile {contactDomain = newClaim} | profileChanged || verifyChanged = do cxt <- chatStoreCxt withFastStore $ \db -> do - ct' <- updateContactProfile db cxt user ct linkProfile + ct' <- updateContactProfile db cxt user ct (Just presHeader) linkProfile if verifyChanged then liftIO $ setContactDomainVerified db user ct' True else pure ct' | otherwise = pure ct where @@ -2250,25 +2254,49 @@ sendDirectContactMessages user ct events = do -- present the user's own badge on an outgoing profile: a fresh, single-use proof from the stored credential. -- the send's incognito profile (when set) suppresses it - an incognito identity must never carry the badge. -- a long-expired badge is not presented at all (receivers would hide it anyway). -presentUserBadge :: User -> Maybe i -> Profile -> CM Profile -presentUserBadge User {profile = LocalProfile {localBadge}} incognitoProfile p = case (incognitoProfile, localBadge) of - (Nothing, Just (OwnBadge cred@(BadgeCredential keyIdx _ _ _) st)) | st == BSActive || st == BSExpired -> do - keys <- asks $ badgePublicKeys . config - case M.lookup keyIdx keys of - Nothing -> p <$ logError "presentUserBadge: badge key index not in config" - Just key -> do - nonce <- drgRandomBytes 16 - liftIO (badgeProof key cred (PHTest nonce)) >>= \case - Right proof -> pure p {badge = Just proof} - Left e -> p <$ logError ("presentUserBadge: proof generation failed: " <> T.pack e) - _ -> pure p +presentUserBadge :: User -> Maybe i -> Maybe ProofPresHeader -> Profile -> CM Profile +presentUserBadge user incognitoProfile = presentUserBadgeWith user incognitoProfile . pure +presentUserBadgeWith :: User -> Maybe i -> CM (Maybe ProofPresHeader) -> Profile -> CM Profile +presentUserBadgeWith user incognitoProfile getPresHeader p + | isNothing incognitoProfile && presentsUserBadge user = do + badge <- getPresHeader $>>= sndBadgeProof user + pure p {badge} + | otherwise = pure p + +presentsUserBadge :: User -> Bool +presentsUserBadge User {profile = LocalProfile {localBadge}} = case localBadge of + Just (OwnBadge _ st) -> st == BSActive || st == BSExpired + _ -> False + +groupPresHeader :: GroupInfo -> Maybe ProofPresHeader +groupPresHeader gInfo@GroupInfo {membership = GroupMember {memberId, memberPubKey}} = memberPresHeader gInfo memberId memberPubKey + +membershipHandshakeProfile :: User -> GroupInfo -> CM Profile +membershipHandshakeProfile user gInfo@GroupInfo {membership} = + presentUserBadge user (incognitoMembershipProfile gInfo) (groupPresHeader gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership + +directPresHeader :: ContactRequestBinding -> ProofPresHeader +directPresHeader = \case + CRBRatchet ConnVerifyCodes {codeAD} -> PHChat $ encodeChatBinding CBDirect $ C.sha256Hash $ "SimpleX badge" <> codeAD + CRBRequest code -> PHRequest code + +linkPresHeader :: ConnShortLink c -> ProofPresHeader +linkPresHeader = \case + CSLInvitation _ _ _ (LinkKey key) -> PHLink key + CSLContact _ _ _ (LinkKey key) -> PHLink key + +connPresHeader :: Connection -> CM (Maybe ProofPresHeader) +connPresHeader conn = M.lookup (aConnId conn) <$> connsPresHeaders [conn] + +connsPresHeaders :: [Connection] -> CM (Map ConnId ProofPresHeader) +connsPresHeaders conns = M.map (directPresHeader . CRBRatchet) <$> withAgent (`getConnectionsVerifyCodes` map aConnId conns) -- receiving side of contact/invitation link data: verify the badge proof from the link profile -- and set the crypto-free display badge for the UI (the raw proof stays in profile for APIPrepareContact) -linkDataBadge :: ContactShortLinkData -> CM ContactShortLinkData -linkDataBadge cld@ContactShortLinkData {profile = Profile {badge}} = case badge of - Nothing -> pure cld +linkDataBadge :: ProofPresHeader -> ContactShortLinkData -> CM ContactShortLinkData +linkDataBadge presHeader cld@ContactShortLinkData {profile = Profile {badge}} = case mfilter (acceptedProof (Just presHeader)) badge of + Nothing -> pure (cld :: ContactShortLinkData) {localBadge = Nothing} Just b@(BadgeProof _ _ _ info) -> do keys <- asks $ badgePublicKeys . config verified <- liftIO $ verifyBadge keys b @@ -2331,7 +2359,11 @@ groupMsgSigning sign (GIK gInfo@GroupInfo {membership = GroupMember {memberId}} where memberPrivKey' = memberPrivKey gks tag = toCMEventTag evt - shouldSign = requiresSignature tag || (sign && signableContent tag) + shouldSign = requiresSignature tag || (sign && signableContent tag) || badgePresented + badgePresented = case evt of + XGrpMemInfo _ Profile {badge} -> isJust badge + XGrpAcpt _ _ (Just Profile {badge}) -> isJust badge + _ -> False bindingData = groupBindingData gInfo memberId (C.publicKey memberPrivKey') groupBindingData :: GroupInfo -> MemberId -> C.PublicKeyEd25519 -> ByteString @@ -2357,6 +2389,16 @@ rcvGroupChatBinding gInfo m_ asGroup badge_ = (\k -> encodeChatBinding CBGroup $ smpEncode (memberId, k)) <$> (memberPubKey <|> proofMemberKey memberId badge_) _ -> Nothing +memberPresHeader :: GroupInfo -> MemberId -> Maybe C.PublicKeyEd25519 -> Maybe ProofPresHeader +memberPresHeader gInfo memberId = fmap $ memberKeyPresHeader ((\PublicGroupProfile {publicGroupId} -> publicGroupId) <$> publicGroup' gInfo) memberId + +memberKeyPresHeader :: Maybe B64UrlByteString -> MemberId -> C.PublicKeyEd25519 -> ProofPresHeader +memberKeyPresHeader publicGroupId_ memberId k = + PHChat $ encodeChatBinding CBGroup $ maybe (smpEncode (memberId, k)) (\publicGroupId -> smpEncode (publicGroupId, memberId, k)) publicGroupId_ + +memberInfoPresHeader :: GroupInfo -> MemberInfo -> Maybe ProofPresHeader +memberInfoPresHeader gInfo MemberInfo {memberId, memberKey} = memberPresHeader gInfo memberId ((\(MemberKey k) -> k) <$> memberKey) + proofMemberKey :: MemberId -> Maybe BadgeProof -> Maybe C.PublicKeyEd25519 proofMemberKey memberId badge_ = do BadgeProof _ (BBSPresHeader phBytes) _ _ <- badge_ @@ -2484,6 +2526,9 @@ encodeSignedConnInfo pqSup signing chatMsgEvent = do ECMEncoded body -> compressConnInfo pqSup $ encodeBatchElement (Just $ signChatMsgBody signing body) body ECMLarge -> throwChatError $ CEException "large signed info" +encodeConnInfoSigning :: MsgEncodingI e => PQSupport -> Maybe MsgSigning -> ChatMsgEvent e -> CM ByteString +encodeConnInfoSigning pqSup = maybe (encodeConnInfoPQ pqSup) (encodeSignedConnInfo pqSup) + -- signed XMember for a relay-group join: proves the joiner holds the member key it asserts, and carries -- viaRelay = the target relay's memberId inside the signed body so a sibling relay can't accept a replay encodeXMemberConnInfo :: PQSupport -> GroupInfoKeys -> MemberId -> Profile -> CM ByteString @@ -2494,6 +2539,14 @@ encodeXMemberConnInfo pqSup (GIK gInfo@GroupInfo {membership = GroupMember {memb signing = MsgSigning CBGroup bindingData KRMember memberPrivKey' in encodeSignedConnInfo pqSup signing xMemberEvt +encodeXGrpAcpt :: User -> GroupInfoKeys -> VersionRangeChat -> CM ByteString +encodeXGrpAcpt user g@(GIK gInfo@GroupInfo {membership = GroupMember {memberId}} gks) peerVRange = do + let incognitoProfile = incognitoMembershipProfile gInfo + profile <- presentUserBadge user incognitoProfile (groupPresHeader gInfo) (userProfileInGroup user gInfo $ fromLocalProfile <$> incognitoProfile) + let msg = XGrpAcpt memberId (Just $ groupMemberKey gks) (Just profile) + signing_ = if maxVersion peerVRange >= relayWebCapVersion then groupMsgSigning False g msg else Nothing + encodeConnInfoSigning PQSupportOff signing_ msg + deliverMessage :: Connection -> CMEventTag e -> MsgBody -> MessageId -> CM (Int64, PQEncryption) deliverMessage conn cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} @@ -2673,7 +2726,7 @@ sendGroupProfileUpdate user g@(GIK gInfo gks) scope asGroup members = _ -> False sendProfileUpdate = do -- shouldSendProfileUpdate excludes incognito membership, so the badge is presented - profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile gInfo (membership gInfo) $ fromLocalProfile p + profileUpdate <- presentUserBadge user Nothing (groupPresHeader gInfo) $ redactedMemberProfile gInfo (membership gInfo) $ fromLocalProfile p void $ sendGroupMessage' user g members $ XInfo profileUpdate (Just $ groupMemberKey gks) currentTs <- liftIO getCurrentTime withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs @@ -3047,13 +3100,11 @@ prepareAgentCreation user cmdFunction enableNtfs cMode = do connId <- withAgent $ \a -> prepareConnectionToCreate a (aUserId user) enableNtfs cMode PQSupportOff pure (cmdId, connId) -prepareAgentJoin :: User -> Maybe Connection -> Bool -> ConnectionRequestUri c -> CM (CommandId, ConnId) -prepareAgentJoin user conn_ enableNtfs cReqUri = do - cmdId <- withStore' $ \db -> createCommand db user (dbConnId <$> conn_) CFJoinConn - connId <- case conn_ of - Just conn -> pure $ aConnId conn - Nothing -> fst <$> withAgent (\a -> prepareConnectionToJoin a (aUserId user) enableNtfs cReqUri PQSupportOff) - pure (cmdId, connId) +prepareAgentJoin :: User -> Bool -> ConnectionRequestUri c -> CM ((CommandId, ConnId), ProofPresHeader) +prepareAgentJoin user enableNtfs cReqUri = do + cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn + (connId, binding) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) enableNtfs cReqUri PQSupportOff + pure ((cmdId, connId), directPresHeader binding) joinAgentConnectionAsync :: CommandId -> Bool -> ConnId -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM () joinAgentConnectionAsync cmdId updateConn connId enableNtfs cReqUri cInfo subMode = @@ -3064,9 +3115,7 @@ allowAgentConnectionAsync user conn@Connection {pqSupport} confId gInfo_ msg = d let signing_ = case gInfo_ of Just gInfo@(GIK g _) | useRelays' g || maxVersion (peerChatVRange conn) >= relayWebCapVersion -> groupMsgSigning False gInfo msg _ -> Nothing - dm <- case signing_ of - Just signing -> encodeSignedConnInfo pqSupport signing msg - Nothing -> encodeConnInfoPQ pqSupport msg + dm <- encodeConnInfoSigning pqSupport signing_ msg allowAgentConnectionInfo user conn confId dm allowAgentConnectionInfo :: User -> Connection -> ConfirmationId -> ByteString -> CM () @@ -3075,11 +3124,11 @@ allowAgentConnectionInfo user conn@Connection {connId} confId dm = do withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm withStore' $ \db -> updateConnectionStatus db conn ConnAccepted -prepareAgentAccept :: User -> Bool -> InvitationId -> PQSupport -> CM (CommandId, ConnId) +prepareAgentAccept :: User -> Bool -> InvitationId -> PQSupport -> CM ((CommandId, ConnId), ProofPresHeader) prepareAgentAccept user enableNtfs invId pqSup = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact - (connId, _) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) enableNtfs invId pqSup - pure (cmdId, connId) + (connId, binding) <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) enableNtfs invId pqSup + pure ((cmdId, connId), directPresHeader binding) agentAcceptContactAsync :: MsgEncodingI e => CommandId -> ConnId -> Bool -> InvitationId -> ChatMsgEvent e -> PQSupport -> SubscriptionMode -> CM () agentAcceptContactAsync cmdId connId enableNtfs invId msg pqSup subMode = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 4ff32429b9..3631331f75 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -46,7 +46,7 @@ import Data.Time.Format (defaultTimeLocale, formatTime) import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Data.Word (Word32) -import Simplex.Chat.Badges (BadgeProof, BadgeProofKind (..), BadgeStatus (..), FileSizeLimits (..), ProofPresHeader (..)) +import Simplex.Chat.Badges (BadgeProof (..), BadgeProofKind (..), BadgeStatus (..), FileSizeLimits (..), ProofPresHeader (..), acceptedBadge, localBadgeStatus, sameBadgeProof, unboundProof) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery @@ -116,10 +116,21 @@ smallGroupsRcptsMemLimit = 20 -- Verifies member signatures over CBGroup <> (publicGroupId, memberId) or (memberId, pubKey) <> signedBody under the given key. -- signatures is NonEmpty so the verification can't be vacuously true. verifyGroupSig :: C.PublicKeyEd25519 -> GroupInfo -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool -verifyGroupSig key gInfo memberId signatures signedBody = - let prefix = encodeChatBinding CBGroup $ groupBindingData gInfo memberId key +verifyGroupSig key gInfo memberId = verifyMemberSig key (groupBindingData gInfo memberId key) + +verifyMemberSig :: C.PublicKeyEd25519 -> ByteString -> NonEmpty MsgSignature -> ByteString -> Bool +verifyMemberSig key bindingData signatures signedBody = + let prefix = encodeChatBinding CBGroup bindingData in all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 key) sig (prefix <> signedBody)) signatures +relayInvPresHeader :: Maybe SignedMsg -> GroupRelayInvitation -> Maybe ProofPresHeader +relayInvPresHeader signedMsg_ GroupRelayInvitation {fromMember = MemberIdRole {memberId}, publicGroupId, fromMemberKey} = do + gId <- publicGroupId + MemberKey k <- fromMemberKey + SignedMsg {chatBinding = CBGroup, signatures, signedBody} <- signedMsg_ + guard $ verifyMemberSig k (smpEncode (gId, memberId)) signatures signedBody + pure $ memberKeyPresHeader (Just gId) memberId k + processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM () processAgentMessage _ _ (DEL_RCVQS delQs) = toView $ CEvtAgentRcvQueuesDeleted $ L.map rcvQ delQs @@ -141,13 +152,13 @@ processAgentMessage corrId connId msg = do Just (user, entity, gks_) -> processAgentMessageConn cxt user entity gks_ corrId connId msg `catchAllErrors` eToView _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) where - getUserEntity :: StoreCxt -> DB.Connection -> ExceptT StoreError IO (Maybe (User, ConnectionEntity, Maybe GroupKeys)) + getUserEntity :: StoreCxt -> DB.Connection -> ExceptT StoreError IO (Maybe (User, ConnectionEntity, Maybe GroupInfoKeys)) getUserEntity cxt db = liftIO (getUserByAConnId db $ AgentConnId connId) >>= mapM (\user -> do (entity, groupKeysData_) <- getConnectionEntityKeys db cxt user (AgentConnId connId) gks_ <- case entity of - RcvGroupMsgConnection _ gInfo _ -> mapM (mkGroupKeys db cxt gInfo) groupKeysData_ + RcvGroupMsgConnection _ gInfo _ -> mapM (mkGroupInfoKeys db cxt gInfo) groupKeysData_ _ -> pure Nothing (user,,gks_) <$> liftIO (updateConnStatus db entity)) @@ -436,7 +447,7 @@ processAgentMsgRcvFile _corrId aFileId msg = do type ShouldDeleteGroupConns = Bool -processAgentMessageConn :: StoreCxt -> User -> ConnectionEntity -> Maybe GroupKeys -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () +processAgentMessageConn :: StoreCxt -> User -> ConnectionEntity -> Maybe GroupInfoKeys -> ACorrId -> ConnId -> AEvent 'AEConn -> CM () processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId agentMessage = case agentMessage of END -> case entity of @@ -446,8 +457,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag _ -> case entity of RcvDirectMsgConnection conn contact_ -> processDirectMessage agentMessage entity conn contact_ - RcvGroupMsgConnection conn gInfo m -> case gks_ of - Just gks -> processGroupMessage agentMessage entity conn (GIK gInfo gks) m + RcvGroupMsgConnection conn _ m -> case gks_ of + Just gks -> processGroupMessage agentMessage entity conn gks m Nothing -> throwChatError $ CEInternalError "group connection entity without group keys" UserContactConnection conn uc -> processContactConnMessage agentMessage entity conn uc @@ -501,10 +512,9 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag -- [incognito] send saved profile (conn'', gInfo_) <- saveConnInfo conn' connInfo incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - profileToSend <- - presentUserBadge user incognitoProfile $ case gInfo_ of - Just (GIK gInfo _) -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) - Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True + profileToSend <- case gInfo_ of + Just (GIK gInfo _) -> presentUserBadge user incognitoProfile (groupPresHeader gInfo) $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + Nothing -> presentUserBadgeWith user incognitoProfile (connPresHeader conn'') $ userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn'' confId gInfo_ $ XInfo profileToSend ((\(GIK _ gks) -> groupMemberKey gks) <$> gInfo_) INFO pqSupport connInfo -> do @@ -621,7 +631,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag ct' <- processContactProfileUpdate ct profile False `catchAllErrors` const (pure ct) -- [incognito] send incognito profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId - p <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True + p <- presentUserBadgeWith user incognitoProfile (connPresHeader conn'') $ userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True allowAgentConnectionAsync user conn'' confId Nothing $ XInfo p Nothing void $ withStore' $ \db -> resetMemberContactFields db ct' XGrpLinkInv glInv -> do @@ -634,7 +644,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile (groupPresHeader gInfo) $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) let gks = GKGroup {memberPrivKey = snd memberKeys} allowAgentConnectionAsync user conn'' confId (Just $ GIK gInfo gks) $ XInfo profileToSend (Just $ groupMemberKey gks) toView $ CEvtBusinessLinkConnecting user gInfo host ct @@ -778,17 +788,21 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" CONF confId _pqSupport _ connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo + (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo conn' <- updatePeerChatVRange conn chatVRange case memberCategory m of GCInviteeMember -> case chatMsgEvent of - XGrpAcpt memId mKey + XGrpAcpt memId mKey memProfile_ | sameMemberId memId m -> do withStore $ \db -> liftIO $ updateGroupMemberStatus db userId m GSMemAccepted - forM_ mKey $ \(MemberKey k) -> withStore' $ \db -> setMemberPubKey db (groupMemberId' m) k + m' <- case mKey of + Just (MemberKey k) -> m {memberPubKey = Just k} <$ withStore' (\db -> setMemberPubKey db (groupMemberId' m) k) + Nothing -> pure m + membershipProfile <- membershipHandshakeProfile user gInfo -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn' confId (Just g) XOk + allowAgentConnectionAsync user conn' confId (Just g) $ XGrpMemInfo (memberId' membership) membershipProfile + forM_ memProfile_ $ \memProfile -> processMemberProfileUpdate gInfo m' signedMsg_ memProfile Nothing | otherwise -> messageError "x.grp.acpt: memberId is different from expected" XGrpRelayAcpt relayLink relayCap | memberRole' membership == GROwner && isRelay m -> do @@ -823,7 +837,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo' (fromLocalProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile (groupPresHeader gInfo') $ userProfileInGroup user gInfo' (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn' confId (Just $ GIK gInfo' gks) $ XInfo profileToSend (Just $ groupMemberKey gks) toView $ CEvtGroupLinkConnecting user gInfo' m' | otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch" @@ -834,31 +848,32 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag _ -> messageError "CONF from host member in prepared group must have x.grp.link.inv or x.grp.link.reject" _ -> case chatMsgEvent of - XGrpMemInfo memId _memProfile + XGrpMemInfo memId memProfile | sameMemberId memId m -> do let GroupMember {memberId = membershipMemId} = membership - membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership - -- TODO update member profile + membershipProfile <- membershipHandshakeProfile user gInfo -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId (Just g) $ XGrpMemInfo membershipMemId membershipProfile + void $ processMemberProfileUpdate gInfo m signedMsg_ memProfile Nothing | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" _ -> messageError "CONF from member must have x.grp.mem.info" INFO _pqSupport connInfo -> do (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo _conn' <- updatePeerChatVRange conn chatVRange case chatMsgEvent of - XGrpMemInfo memId _memProfile - | sameMemberId memId m -> do - -- TODO update member profile - pure () + XGrpMemInfo memId memProfile + | sameMemberId memId m -> + void $ processMemberProfileUpdate gInfo m signedMsg_ memProfile Nothing | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" -- sent when connecting via group link - XInfo _ mKey + XInfo memProfile mKey -- TODO Keep rejected member to allow them to appeal against rejection. | memberStatus m == GSMemRejected -> do deleteMemberConnection' m True withStore' $ \db -> deleteGroupMember db user m - | otherwise -> mapM_ (storeMemberKey gInfo m signedMsg_) mKey + | otherwise -> do + m' <- maybe (pure m) (storeMemberKey gInfo m signedMsg_) mKey + void $ processMemberProfileUpdate gInfo m' signedMsg_ memProfile Nothing XOk -> -- transient relay-reject row cleanup after the rejection handshake completes when (memberCategory m == GCHostMember && not (relayServesGroup gInfo)) $ do @@ -967,7 +982,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag where sendXGrpLinkMem gInfo'' m' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' - profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo'' (fromIncognitoProfile <$> incognitoProfile) + profileToSend <- presentUserBadge user incognitoProfile (groupPresHeader gInfo'') $ userProfileInGroup user gInfo'' (fromIncognitoProfile <$> incognitoProfile) sendGroupMemberMessages user (GIK gInfo'' gks) conn [XGrpLinkMem profileToSend (Just $ groupMemberKey gks)] _ -> do unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected @@ -1092,7 +1107,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew (GIK gInfo' gks) 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 + XGrpMemFwd memInfo introInv -> Nothing <$ xGrpMemFwd (GIK gInfo' gks) m'' memInfo introInv XGrpMemRole memId memRole memberKey rosterVer -> fmap ctx <$> xGrpMemRole (GIK gInfo' gks) Nothing m'' memId memRole memberKey rosterVer msg brokerTs XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId @@ -1249,11 +1264,11 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag -- Update connection with data derived from cReq, now available after getConnShortLinkAsync withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup let incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo - profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo incognitoProfile + profileToSend <- presentUserBadge user incognitoProfile (groupPresHeader gInfo) $ userProfileInGroup user gInfo incognitoProfile dm <- encodeXMemberConnInfo pqSup g relayMemberId profileToSend subMode <- chatReadVar subscriptionMode - (cmdId, connId') <- prepareAgentJoin user (Just conn) True cReq - joinAgentConnectionAsync cmdId True connId' True cReq dm subMode + cmdId <- withStore' $ \db -> createCommand db user (Just $ dbConnId conn) CFJoinConn + joinAgentConnectionAsync cmdId True (aConnId conn) True cReq dm subMode CFGetRelayDataAccept -> do let GroupMember {memberId = MemberId expectedMemberId} = m if linkEntityId == Just expectedMemberId @@ -1393,13 +1408,14 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag processContactConnMessage :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM () processContactConnMessage agentMsg connEntity conn UserContact {userContactLinkId = uclId, groupId = ucGroupId_} = case agentMsg of - REQ invId pqSupport _ connInfo _ rejectionSupported -> do + REQ invId pqSupport _ connInfo binding rejectionSupported -> do (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo + let reqHeader = directPresHeader binding case chatMsgEvent of - XContact p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ pqSupport rejectionSupported + XContact p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange reqHeader p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ pqSupport rejectionSupported XMember p joiningMemberId joiningMemberKey viaRelay -> memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey viaRelay - XInfo p _ -> profileContactRequest invId chatVRange p Nothing Nothing Nothing Nothing pqSupport rejectionSupported - XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv + XInfo p _ -> profileContactRequest invId chatVRange reqHeader p Nothing Nothing Nothing Nothing pqSupport rejectionSupported + XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange signedMsg_ groupRelayInv XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge -- TODO show/log error, other events in contact request _ -> pure () @@ -1472,8 +1488,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag -- TODO add debugging output _ -> pure () where - profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe MemberKey -> Maybe XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> PQSupport -> Bool -> CM () - profileContactRequest invId chatVRange p@Profile {displayName} memberKey_ xContactId_ welcomeMsgId_ requestMsg_ reqPQSup rejectionSupported = do + profileContactRequest :: InvitationId -> VersionRangeChat -> ProofPresHeader -> Profile -> Maybe MemberKey -> Maybe XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> PQSupport -> Bool -> CM () + profileContactRequest invId chatVRange reqHeader p@Profile {displayName} memberKey_ xContactId_ welcomeMsgId_ requestMsg_ reqPQSup rejectionSupported = do (ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId let v = maxVersion chatVRange case gLinkInfo_ of @@ -1483,7 +1499,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag AddressSettings {autoAccept} = addressSettings isSimplexTeam = sameConnReqContact connReq adminContactReq gVar <- asks random - withStore (\db -> createOrUpdateContactRequest db gVar cxt user uclId ucl isSimplexTeam invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ reqPQSup rejectionSupported) >>= \case + withStore (\db -> createOrUpdateContactRequest db gVar cxt user uclId ucl isSimplexTeam invId chatVRange (Just reqHeader) p xContactId_ welcomeMsgId_ requestMsg_ reqPQSup rejectionSupported) >>= \case RSAcceptedRequest _ucr re -> case re of REContact ct -> -- TODO [short links] update request msg @@ -1626,7 +1642,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case Right (acceptance, useRole) -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - mem <- acceptGroupJoinRequestAsync user uclId g invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode memberKey_ Nothing + mem <- acceptGroupJoinRequestAsync user uclId g invId chatVRange p (Just reqHeader) xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode memberKey_ Nothing (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' @@ -1636,16 +1652,21 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | otherwise -> do mem <- acceptGroupJoinSendRejectAsync user uclId gInfo invId chatVRange p xContactId_ rjctReason toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason - xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM () - xGrpRelayInv invId chatVRange groupRelayInv@GroupRelayInvitation {groupLink} = do - rejected <- withStore' $ \db -> isRelayGroupRejected db user groupLink - initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config - if rejected - then rejectRelayInvitationAsync user uclId cxt groupRelayInv invId chatVRange initialDelay RRRRejoinRejected - else do - (_gInfo, _ownerMember) <- withStore $ \db -> - createRelayRequestGroup db cxt user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited - lift $ void $ getRelayRequestWorker True + xGrpRelayInv :: InvitationId -> VersionRangeChat -> Maybe SignedMsg -> GroupRelayInvitation -> CM () + xGrpRelayInv invId chatVRange signedMsg_ groupRelayInv@GroupRelayInvitation {groupLink} + | maxVersion chatVRange >= signedRelayInvVersion && isNothing presHeader_ = + messageError "x.grp.relay.inv: invitation is not signed by the owner" + | otherwise = do + rejected <- withStore' $ \db -> isRelayGroupRejected db user groupLink + initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config + if rejected + then rejectRelayInvitationAsync user uclId cxt groupRelayInv presHeader_ invId chatVRange initialDelay RRRRejoinRejected + else do + (_gInfo, _ownerMember) <- withStore $ \db -> + createRelayRequestGroup db cxt user groupRelayInv presHeader_ invId chatVRange initialDelay GSMemAccepted RSInvited + lift $ void $ getRelayRequestWorker True + where + presHeader_ = relayInvPresHeader signedMsg_ groupRelayInv xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () xGrpRelayTest invId chatVRange challenge | isTrue userChatRelay && isNothing ucGroupId_ = @@ -1656,7 +1677,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag msg = XGrpRelayTest challenge (Just sig) subMode <- chatReadVar subscriptionMode let chatV = vr cxt `peerConnChatVersion` chatVRange - (cmdId, acId) <- prepareAgentAccept user True invId PQSupportOff + ((cmdId, acId), _) <- prepareAgentAccept user True invId PQSupportOff withStore $ \db -> do Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode liftIO $ setCommandConnId db user cmdId testCId @@ -1672,27 +1693,27 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do g@(GIK gInfo _) <- withStore $ \db -> getGroupInfoKeys db cxt user groupId existing_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberByMemberId db cxt user gInfo joiningMemberId) + let signed = memberSigned gInfo joiningMemberId joiningKey signedMsg_ case existing_ of Just rosterMem -- a privileged memberId's key is owner-authoritative (the roster); the joiner must prove -- possession of that exact key, otherwise this is an attempt to impersonate it | isRosterRole (memberRole' rosterMem) -> - if verifyKey gInfo rosterMem - then acceptJoin g (Just rosterMem) (memberRole' rosterMem) + if verifyKey gInfo rosterMem signed + then acceptJoin g signed (Just rosterMem) (memberRole' rosterMem) else messageError "memberJoinRequestViaRelay: rejected join claiming privileged memberId (key mismatch or invalid signature)" - _ -> acceptJoin g Nothing gLinkMemRole + _ -> acceptJoin g signed Nothing gLinkMemRole Nothing -> messageError "memberJoinRequestViaRelay: no group link info for relay link" where -- replay defense: the viaRelay == own memberId check (viaRelay is in the signed body); without it a sibling relay could replay a privileged member's signed join - verifyKey gInfo rosterMem = case signedMsg_ of - Just SignedMsg {chatBinding = CBGroup, signatures, signedBody} -> - memberPubKey rosterMem == Just joiningKey - && verifyGroupSig joiningKey gInfo joiningMemberId signatures signedBody - && viaRelay == Just (memberId' (membership gInfo)) - _ -> False - acceptJoin g@(GIK gInfo _) existingMem_ acceptRole = do - mem <- acceptGroupJoinRequestAsync user uclId g invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ + verifyKey gInfo rosterMem signed = + memberPubKey rosterMem == Just joiningKey + && signed + && viaRelay == Just (memberId' (membership gInfo)) + acceptJoin g@(GIK gInfo _) signed existingMem_ acceptRole = do + let presHeader_ = memberPresHeader gInfo joiningMemberId $ if signed then Just joiningKey else Nothing + mem <- acceptGroupJoinRequestAsync user uclId g invId chatVRange p presHeader_ Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_ (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' @@ -2689,13 +2710,13 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag memberKeys <- atomically . C.generateKeyPair =<< asks random (GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} gks, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId memberKeys void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) - let GroupMember {groupMemberId, memberId = membershipMemId} = membership + let GroupMember {groupMemberId} = membership -- hostContact is only reported for group links, where the client replaces -- the transient host connection view with the group and removes its chat joinGroupAsync hostContact_ sameLink = do subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XGrpAcpt membershipMemId (Just $ groupMemberKey gks) - connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest + dm <- encodeXGrpAcpt user (GIK gInfo gks) peerChatVRange + (connIds@(cmdId, acId), _) <- prepareAgentJoin user True connRequest withStore' $ \db -> do when sameLink $ setViaGroupLinkUri db groupId connId createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode @@ -2756,25 +2777,28 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag brokerTs = metaBrokerTs msgMeta processContactProfileUpdate :: Contact -> Profile -> Bool -> CM Contact - processContactProfileUpdate c@Contact {profile = lp} p' createItems + processContactProfileUpdate c@Contact {profile = lp} p'@Profile {badge = rcvBadge} createItems = do + presHeader_ <- if any (not . unboundProof) rcvBadge then pure (contactConn c) $>>= connPresHeader else pure Nothing + let p'' = p' {badge = acceptedBadge presHeader_ storedBadge rcvBadge} + contentChanged = not (sameProfileContent p p'') -- a failed/unknown-key badge is re-verified even when content is unchanged, so it heals after an app update adds the key - | contentChanged || badgeNeedsReverify lp = do - c' <- withStore $ \db -> - if userTTL == rcvTTL - then updateContactProfile db cxt user c p' - else do - c' <- liftIO $ updateContactUserPreferences db user c ctUserPrefs' - updateContactProfile db cxt user c' p' - when (contentChanged && directOrUsed c' && createItems) $ do - createProfileUpdatedItem c' - lift $ createRcvFeatureItems user c c' - toView $ CEvtContactUpdated user c c' - pure c' - | otherwise = - pure c + if + | contentChanged || badgeNeedsReverify lp -> do + c' <- withStore $ \db -> + if userTTL == rcvTTL + then updateContactProfile db cxt user c presHeader_ p' + else do + c' <- liftIO $ updateContactUserPreferences db user c ctUserPrefs' + updateContactProfile db cxt user c' presHeader_ p' + when (contentChanged && directOrUsed c' && createItems) $ do + createProfileUpdatedItem c' p'' + lift $ createRcvFeatureItems user c c' + toView $ CEvtContactUpdated user c c' + pure c' + | otherwise -> + pure c where - contentChanged = not (sameProfileContent p p') - p = fromLocalProfile lp + p@Profile {badge = storedBadge} = fromLocalProfile lp Contact {userPreferences = ctUserPrefs@Preferences {timedMessages = ctUserTMPref}} = c userTTL = prefParam $ getPreference SCFTimedMessages ctUserPrefs Profile {preferences = rcvPrefs_} = p' @@ -2788,20 +2812,20 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | rcvTTL /= userDefaultTTL -> Just (userDefault :: TimedMessagesPreference) {ttl = rcvTTL} | otherwise -> Nothing in setPreference_ SCFTimedMessages ctUserTMPref' ctUserPrefs - createProfileUpdatedItem c' = + createProfileUpdatedItem c' p'' = when visibleProfileUpdated $ do - let ciContent = CIRcvDirectEvent $ RDEProfileUpdated p p' + let ciContent = CIRcvDirectEvent $ RDEProfileUpdated p p'' createInternalChatItem user (CDDirectRcv c') ciContent Nothing where visibleProfileUpdated = n' /= n || fn' /= fn || sd /= sd' || i' /= i || cl' /= cl Profile {displayName = n, fullName = fn, shortDescr = sd, image = i, contactLink = cl} = p - 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 -> Maybe MemberKey -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xInfoMember gInfo m p' mKey msg@RcvMessage {signedMsg_} brokerTs = do - mapM_ (storeMemberKey gInfo m signedMsg_) mKey - void $ processMemberProfileUpdate gInfo m p' (Just (msg, brokerTs)) + m' <- maybe (pure m) (storeMemberKey gInfo m signedMsg_) mKey + void $ processMemberProfileUpdate gInfo m' signedMsg_ p' (Just (msg, brokerTs)) pure $ memberEventDeliveryScope m xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> Maybe MemberKey -> RcvMessage -> CM () @@ -2809,23 +2833,30 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId if (viaGroupLink || isJust businessChat) && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived then do - mapM_ (storeMemberKey gInfo m signedMsg_) mKey - m' <- processMemberProfileUpdate gInfo m p' Nothing + m' <- maybe (pure m) (storeMemberKey gInfo m signedMsg_) mKey + m'' <- processMemberProfileUpdate gInfo m' signedMsg_ p' Nothing withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True let connectedIncognito = memberIncognito membership - probeMatchingMemberContact m' connectedIncognito + probeMatchingMemberContact m'' connectedIncognito else messageError "x.grp.link.mem error: invalid group link host profile update" - storeMemberKey :: GroupInfo -> GroupMember -> Maybe SignedMsg -> MemberKey -> CM () - storeMemberKey gInfo GroupMember {groupMemberId, memberPubKey, memberId} signedMsg_ (MemberKey k) = case memberPubKey of - Just k0 -> when (k /= k0) $ messageError "member key change rejected, keeping current key" + storeMemberKey :: GroupInfo -> GroupMember -> Maybe SignedMsg -> MemberKey -> CM GroupMember + storeMemberKey gInfo m@GroupMember {groupMemberId, memberPubKey, memberId} signedMsg_ (MemberKey k) = case memberPubKey of + Just k0 -> m <$ when (k /= k0) (messageError "member key change rejected, keeping current key") Nothing - | signed -> withStore' $ \db -> setMemberPubKey db groupMemberId k - | otherwise -> messageError "member key not signed by that key, ignored" + | signed -> m {memberPubKey = Just k} <$ withStore' (\db -> setMemberPubKey db groupMemberId k) + | otherwise -> m <$ messageError "member key not signed by that key, ignored" where - signed = case signedMsg_ of - Just SignedMsg {chatBinding = CBGroup, signatures, signedBody} -> verifyGroupSig k gInfo memberId signatures signedBody - _ -> False + signed = memberSigned gInfo memberId k signedMsg_ + + memberSigned :: GroupInfo -> MemberId -> C.PublicKeyEd25519 -> Maybe SignedMsg -> Bool + memberSigned gInfo memberId k = \case + Just SignedMsg {chatBinding = CBGroup, signatures, signedBody} -> verifyGroupSig k gInfo memberId signatures signedBody + _ -> False + + signedMemberPresHeader :: GroupInfo -> GroupMember -> Maybe SignedMsg -> Maybe ProofPresHeader + signedMemberPresHeader gInfo GroupMember {memberId, memberPubKey} signedMsg_ = + memberPresHeader gInfo memberId $ mfilter (\k -> memberSigned gInfo memberId k signedMsg_) memberPubKey xGrpLinkAcpt :: GroupInfoKeys -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM () xGrpLinkAcpt g@(GIK gInfo@GroupInfo {membership} _) m acceptance role memberId msg brokerTs @@ -2886,15 +2917,15 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag unless (groupFeatureAllowed SGFHistory gInfo) $ forM_ description $ \descr -> createInternalChatItem user (CDGroupRcv gInfo Nothing m) (CIRcvMsgContent $ MCText descr) Nothing - processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Maybe (RcvMessage, UTCTime) -> CM GroupMember - processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' msgTs_ + processMemberProfileUpdate :: GroupInfo -> GroupMember -> Maybe SignedMsg -> Profile -> Maybe (RcvMessage, UTCTime) -> CM GroupMember + processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId, memberBadgeProof = NoJSON storedMemberProof} signedMsg_ rcvProfile@Profile {badge = rcvBadge} msgTs_ -- a failed/unknown-key badge is re-verified even when content is unchanged, so it heals after an app update adds the key - | contentChanged || badgeNeedsReverify p = do + | contentChanged || badgeNeedsReverify p || memberProofChanged = do when contentChanged $ updateBusinessChatProfile gInfo case memberContactId of Nothing -> do - m' <- withStore $ \db -> updateMemberProfile db cxt user m p'' - unless (muteEventInChannel gInfo m') $ do + m' <- withStore $ \db -> updateMemberProfile db cxt user m presHeader_ (redactedMemberProfile gInfo m rcvProfile) + unless (muteEventInChannel gInfo m' || noVisibleChange m') $ do when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' pure m' @@ -2902,16 +2933,22 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag mCt <- withStore $ \db -> getContact db cxt user mContactId if contactUpdatableFromMember mCt then do - (m', ct') <- withStore $ \db -> updateContactMemberProfile db cxt user m mCt p' - unless (muteEventInChannel gInfo m') $ do + (m', ct') <- withStore $ \db -> updateContactMemberProfile db cxt user m mCt presHeader_ rcvProfile + unless (muteEventInChannel gInfo m' || noVisibleChange m') $ do when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m' toView $ CEvtGroupMemberUpdated user gInfo m m' toView $ CEvtContactUpdated user mCt ct' pure m' - else pure m + else withStore' $ \db -> setMemberBadgeProof db cxt m presHeader_ rcvProfile | otherwise = pure m where + noVisibleChange m' = not contentChanged && memberBadgeStatus m' == memberBadgeStatus m + memberBadgeStatus GroupMember {memberProfile = LocalProfile {localBadge}} = localBadgeStatus <$> localBadge + presHeader_ = signedMemberPresHeader gInfo m signedMsg_ + p' = rcvProfile {badge = acceptedBadge presHeader_ storedProof rcvBadge} + memberProofChanged = not $ sameBadgeProof (acceptedBadge presHeader_ storedMemberProof rcvBadge) storedMemberProof + Profile {badge = storedProof} = fromLocalProfile p p'' = redactedMemberProfile gInfo m p' contentChanged = not (sameProfileContent (redactedMemberProfile gInfo m (fromLocalProfile p)) p'') updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of @@ -3165,8 +3202,9 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage activeConn connInfo conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of - XInfo p _ -> do - ct <- withStore $ \db -> createDirectContact db cxt user conn' p + XInfo p@Profile {badge} _ -> do + presHeader_ <- if any (not . unboundProof) badge then connPresHeader conn' else pure Nothing + ct <- withStore $ \db -> createDirectContact db cxt user conn' presHeader_ p toView $ CEvtContactConnecting user ct pure (conn', Nothing) XGrpLinkInv glInv -> do @@ -3190,6 +3228,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag xGrpMemNew :: GroupInfoKeys -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemNew (GIK gInfo gks) m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do unless (useRelays' gInfo) $ checkHostRole m memRole + let presHeader_ = memberInfoPresHeader gInfo memInfo if sameMemberId memId (membership gInfo) then pure Nothing else @@ -3206,7 +3245,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag -- TODO [relays] member: surface relay-key-mismatch as a dedicated event / chat item / relay state when (assertedKey /= memberPubKey unknownMember) $ messageWarning $ "x.grp.mem.new: relay asserted key differs from roster-established key, keeping roster key, memberId=" <> safeDecodeUtf8 (strEncode memId) - updatedMember <- withStore $ \db -> updateRosterMemberAnnounced db cxt user m unknownMember memInfo initialStatus + updatedMember <- withStore $ \db -> updateRosterMemberAnnounced db cxt user m unknownMember memInfo (memberPresHeader gInfo memId (memberPubKey unknownMember)) initialStatus -- roster members can't be pending, so no members-require-attention update gInfo' <- updatePublicGroupData user gInfo gks toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember @@ -3217,7 +3256,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag messageError "x.grp.mem.new: privileged role not established by roster" $> Nothing | otherwise -> do (updatedMember, gInfo') <- withStore $ \db -> do - updatedMember <- updateUnknownMemberAnnounced db cxt user m unknownMember memInfo initialStatus + updatedMember <- updateUnknownMemberAnnounced db cxt user m unknownMember memInfo presHeader_ initialStatus gInfo' <- if memberPending updatedMember then liftIO $ increaseGroupMembersRequireAttention db user gInfo @@ -3235,7 +3274,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | useRelays' gInfo && isPrivilegedRole memRole -> messageError "x.grp.mem.new: privileged member not established by roster" $> Nothing | otherwise -> do (newMember, gInfo') <- withStore $ \db -> do - newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus + newMember <- createNewGroupMember db cxt user gInfo m memInfo presHeader_ GCPostMember initialStatus gInfo' <- if memberPending newMember then liftIO $ increaseGroupMembersRequireAttention db user gInfo @@ -3276,7 +3315,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case Right existingMember | useRelays' gInfo -> do - updatedMember <- withStore $ \db -> updatePreparedChannelMember db cxt user existingMember memInfo + updatedMember <- withStore $ \db -> updatePreparedChannelMember db cxt user existingMember memInfo (memberPresHeader gInfo memId (memberPubKey existingMember)) toView $ CEvtGroupMemberUpdated user gInfo existingMember updatedMember | otherwise -> messageError "x.grp.mem.intro ignored: member already exists" @@ -3289,7 +3328,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag MemberInfo mId mRole v p _ | mRole >= GRMember -> MemberInfo mId defaultRole v p Nothing _ -> memInfo - void $ withStore $ \db -> createIntroReMember db cxt user gInfo memInfo' memRestrictions + void $ withStore $ \db -> createIntroReMember db cxt user gInfo memInfo' (memberInfoPresHeader gInfo memInfo') memRestrictions | otherwise -> do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) case memChatVRange of @@ -3299,7 +3338,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag groupConnIds@(cmdId, connId) <- prepareAgentCreation user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation let chatV = maybe (minVersion (vr cxt)) (\peerVR -> vr cxt `peerConnChatVersion` fromChatVRange peerVR) memChatVRange void $ withStore $ \db -> do - reMember <- createIntroReMember db cxt user gInfo memInfo memRestrictions + reMember <- createIntroReMember db cxt user gInfo memInfo (memberInfoPresHeader gInfo memInfo) memRestrictions createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) connId (chatHasNtfs chatSettings) SCMInvitation CR.IKPQOff True subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" @@ -3320,8 +3359,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag Right reMember -> sendGroupMemberMessage gInfo reMember $ XGrpMemFwd (memberInfo gInfo m) introInv _ -> 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 _ _) IntroInvitation {groupConnReq} = do + xGrpMemFwd :: GroupInfoKeys -> GroupMember -> MemberInfo -> IntroInvitation -> CM () + xGrpMemFwd g@(GIK gInfo@GroupInfo {membership, chatSettings} _) m memInfo@(MemberInfo memId memRole memChatVRange _ _) IntroInvitation {groupConnReq} = do let GroupMember {memberId = membershipMemId} = membership checkHostRole m memRole toMember <- withStore $ \db -> do @@ -3332,7 +3371,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. -- For now, this branch compensates for the lack of delayed message delivery. `catchError` \case - SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db cxt user gInfo m memInfo GCPostMember GSMemAnnounced + SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db cxt user gInfo m memInfo (memberInfoPresHeader gInfo memInfo) GCPostMember GSMemAnnounced e -> throwError e -- TODO [knocking] separate pending statuses from GroupMemberStatus? -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? @@ -3341,14 +3380,16 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag liftIO $ updateGroupMemberStatus db userId toMember newMemberStatus pure toMember subMode <- chatReadVar subscriptionMode - -- [incognito] send membership incognito profile - membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership - dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile - -- [async agent commands] no continuation needed, but commands should be asynchronous for stability - let enableNtfsGrp = chatHasNtfs chatSettings - groupConnIds@(gCmdId, gAcId) <- prepareAgentJoin user Nothing enableNtfsGrp groupConnReq let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange chatV = vr cxt `peerConnChatVersion` mcvr + -- [incognito] send membership incognito profile + membershipProfile <- membershipHandshakeProfile user gInfo + let msg = XGrpMemInfo membershipMemId membershipProfile + signing_ = if chatV >= relayWebCapVersion then groupMsgSigning False g msg else Nothing + dm <- encodeConnInfoSigning PQSupportOff signing_ msg + -- [async agent commands] no continuation needed, but commands should be asynchronous for stability + let enableNtfsGrp = chatHasNtfs chatSettings + (groupConnIds@(gCmdId, gAcId), _) <- prepareAgentJoin user enableNtfsGrp groupConnReq withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds subMode joinAgentConnectionAsync gCmdId False gAcId enableNtfsGrp groupConnReq dm subMode @@ -3874,12 +3915,12 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag } joinExistingContact subMode mCt@Contact {contactId = mContactId} | autoAcceptMemberContacts user = do - (cmdId, acId) <- prepareAgentJoin user Nothing True connReq + ((cmdId, acId), presHeader) <- prepareAgentJoin user True connReq mCt' <- withStore $ \db -> do updateMemberContactInvited db user mCt groupDirectInv void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode getContact db cxt user mContactId - joinMemberContactAsync cmdId acId subMode + joinMemberContactAsync cmdId acId presHeader subMode securityCodeChanged mCt' createItems mCt' m | otherwise = do @@ -3893,14 +3934,14 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag createItems mCt' m createNewContact subMode | autoAcceptMemberContacts user = do - (cmdId, acId) <- prepareAgentJoin user Nothing True connReq + ((cmdId, acId), presHeader) <- prepareAgentJoin user True connReq -- [incognito] reuse membership incognito profile (mCt, m') <- withStore $ \db -> do (mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode mCt <- getContact db cxt user mContactId pure (mCt, m') - joinMemberContactAsync cmdId acId subMode + joinMemberContactAsync cmdId acId presHeader subMode createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart) createItems mCt m' | otherwise = do @@ -3913,9 +3954,9 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart) createInternalChatItem user (CDDirectRcv mCt) (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing createItems mCt m' - joinMemberContactAsync cmdId acId subMode = do + joinMemberContactAsync cmdId acId presHeader subMode = do -- [incognito] send membership incognito profile - p <- presentUserBadge user (incognitoMembershipProfile g) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True + p <- presentUserBadge user (incognitoMembershipProfile g) (Just presHeader) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True -- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ) dm <- encodeConnInfo $ XInfo p Nothing joinAgentConnectionAsync cmdId False acId True connReq dm subMode @@ -4494,22 +4535,23 @@ runRelayRequestWorker a Worker {doWork} = do eToView e processRelayRequest :: GroupId -> RelayRequestData -> CM () processRelayRequest groupId rrd = do - (g@(GIK gInfo _), groupLink_) <- withStore $ \db -> do + (g@(GIK gInfo _), groupLink_, ownerMember) <- withStore $ \db -> do g@(GIK gInfo _) <- getGroupInfoKeys db cxt user groupId groupLink_ <- liftIO $ runExceptT $ getGroupLink db user gInfo - pure (g, groupLink_) + ownerMember <- getHostMember db cxt user groupId + pure (g, groupLink_, ownerMember) -- Check if relay link already exists (recovery case) case groupLink_ of Right GroupLink {connLinkContact = CCLink _ sLnk_} -> case sLnk_ of - Just sLnk -> acceptOwnerConnection rrd gInfo sLnk + Just sLnk -> acceptOwnerConnection rrd gInfo ownerMember sLnk Nothing -> throwChatError $ CEException "processRelayRequest: relay link doesn't have short link" Left _ -> do - (gInfo', sLnk) <- getLinkDataCreateRelayLink rrd g - acceptOwnerConnection rrd gInfo' sLnk + (gInfo', sLnk) <- getLinkDataCreateRelayLink rrd g ownerMember + acceptOwnerConnection rrd gInfo' ownerMember sLnk where - getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfoKeys -> CM (GroupInfo, ShortLinkContact) - getLinkDataCreateRelayLink RelayRequestData {reqGroupLink} (GIK gInfo gks) = do + getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfoKeys -> GroupMember -> CM (GroupInfo, ShortLinkContact) + getLinkDataCreateRelayLink RelayRequestData {reqGroupLink, reqPublicGroupId} (GIK gInfo gks) GroupMember {memberId = MemberId ownerMemberId, memberPubKey = claimedOwnerKey_} = do memberPrivKey' <- case gks of GKRelayRequest {memberPrivKey} -> pure memberPrivKey _ -> throwChatError $ CEException "getLinkDataCreateRelayLink: group is not a relay request" @@ -4519,8 +4561,10 @@ runRelayRequestWorker a Worker {doWork} = do Just GroupShortLinkData {groupProfile = gp@GroupProfile {publicGroup}} -> do pg <- case (linkEntityId, publicGroup) of (Just entityId, Just pg@PublicGroupProfile {publicGroupId}) - | B64UrlByteString entityId == publicGroupId -> pure pg - _ -> throwChatError $ CEException "getLinkDataCreateRelayLink: linkEntityId does not match profile publicGroupId" + | B64UrlByteString entityId == publicGroupId && all (== publicGroupId) reqPublicGroupId -> pure pg + _ -> throwChatError $ CEException "getLinkDataCreateRelayLink: linkEntityId does not match publicGroupId of profile or invitation" + unless (all (\k -> any (\OwnerAuth {ownerId, ownerKey} -> ownerId == ownerMemberId && ownerKey == k) owners) claimedOwnerKey_) $ + throwChatError $ CEException "getLinkDataCreateRelayLink: owner key of invitation does not match link data" validateGroupProfile gp sLnk <- createRelayLink gInfo (C.publicKey memberPrivKey', memberPrivKey') gInfo' <- withStore $ \db -> do @@ -4542,7 +4586,7 @@ runRelayRequestWorker a Worker {doWork} = do subMode <- chatReadVar subscriptionMode let crClientData = encodeJSON $ CRDataGroup groupLinkId -- prepare link with relayMemId as linkEntityId (no server request) - (ccLink, preparedParams) <- withAgent $ \a' -> prepareConnectionLink a' (aUserId user) sigKeys relayMemId True (Just crClientData) CR.IKPQOff False Nothing + (ccLink, preparedParams) <- withAgent $ \a' -> prepareConnectionLink a' (aUserId user) SCMContact sigKeys (Just relayMemId) True (Just crClientData) CR.IKPQOff False Nothing ccLink' <- setShortLinkType CCTGroup <$> shortenCreatedLink ccLink sLnk <- case connShortLink' ccLink' of Just sl -> pure sl @@ -4550,12 +4594,11 @@ runRelayRequestWorker a Worker {doWork} = do let userData = encodeShortLinkData $ RelayShortLinkData {relayProfile = fromLocalProfile p} userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData, ratchetKeys = Nothing} -- create connection with prepared link (single network call) - connId <- withAgent $ \a' -> createConnectionForLink a' NRMBackground (aUserId user) True ccLink preparedParams userLinkData subMode + (connId, _) <- withAgent $ \a' -> createConnectionForLink a' NRMBackground (aUserId user) True ccLink preparedParams userLinkData subMode -- TODO [relays] starting role should be communicated in protocol from owner to relays subRole <- asks $ channelSubscriberRole . config void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId subRole subMode pure sLnk - acceptOwnerConnection :: RelayRequestData -> GroupInfo -> ShortLinkContact -> CM () - acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi relayLink = do - ownerMember <- withStore $ \db -> getHostMember db cxt user groupId + acceptOwnerConnection :: RelayRequestData -> GroupInfo -> GroupMember -> ShortLinkContact -> CM () + acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi ownerMember relayLink = void $ acceptRelayJoinRequestAsync user uclId gi ownerMember relayInvId reqChatVRange relayLink diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 01cb868e83..aad0254b52 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -92,7 +92,7 @@ import Simplex.Messaging.Version hiding (version) -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 21 +currentChatVersion = VersionChat 22 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -143,6 +143,9 @@ groupMemberKeyVersion = VersionChat 20 anyTextCommandsVersion :: VersionChat anyTextCommandsVersion = VersionChat 21 +signedRelayInvVersion :: VersionChat +signedRelayInvVersion = VersionChat 22 + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -468,7 +471,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey, viaRelay :: Maybe MemberId} -> ChatMsgEvent 'Json XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json - XGrpAcpt :: MemberId -> Maybe MemberKey -> ChatMsgEvent 'Json + XGrpAcpt :: MemberId -> Maybe MemberKey -> Maybe Profile -> ChatMsgEvent 'Json XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json XGrpLinkMem :: Profile -> Maybe MemberKey -> ChatMsgEvent 'Json @@ -1362,6 +1365,7 @@ requiresSignature = \case XGrpRoster_ -> True XInfo_ -> True XGrpLinkMem_ -> True + XGrpRelayInv_ -> True _ -> False -- | Content events a member may sign (XMsgNew opt-in; XMsgUpdate/XMsgDel when the target was signed). @@ -1441,7 +1445,7 @@ appJsonToCM fwdDepth AppMessageJson {v, msgId, event, params} = do XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" <*> opt "viaRelay" XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" - XGrpAcpt_ -> XGrpAcpt <$> p "memberId" <*> opt "memberKey" + XGrpAcpt_ -> XGrpAcpt <$> p "memberId" <*> opt "memberKey" <*> opt "profile" XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection" XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" <*> opt "memberKey" @@ -1518,7 +1522,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XMember {profile, newMemberId, newMemberKey, viaRelay} -> o $ ("viaRelay" .=? viaRelay) ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey] XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] - XGrpAcpt memId memberKey -> o $ ("memberKey" .=? memberKey) ["memberId" .= memId] + XGrpAcpt memId memberKey profile -> o $ ("profile" .=? profile) $ ("memberKey" .=? memberKey) ["memberId" .= memId] XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct] XGrpLinkMem profile memberKey -> o $ ("memberKey" .=? memberKey) ["profile" .= profile] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 0867118cb6..0a58b1cfd9 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -31,7 +31,7 @@ import Data.Bitraversable (bitraverse) import Data.Int (Int64) import Data.Maybe (fromMaybe) import Data.Time.Clock (UTCTime, getCurrentTime) -import Simplex.Chat.Badges (rowToBadge) +import Simplex.Chat.Badges (MaybeBadgeProofRow, maybeRowToBadgeProof, rowToBadge) import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups @@ -175,23 +175,25 @@ getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = d 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.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, 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, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_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, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra FROM group_members m 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 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 group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) |] (groupMemberId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) liftIO $ bitraverse (\(g, keysData) -> (,keysData) <$> addGroupChatTags db g) pure gm - toGroupAndMember :: UTCTime -> Connection -> GroupInfoRow :. GroupMemberRow -> ((GroupInfo, GroupKeysRow), GroupMember) - toGroupAndMember currentTs c (groupInfoRow :. memberRow) = + toGroupAndMember :: UTCTime -> Connection -> GroupInfoRow :. GroupMemberRow :. MaybeBadgeProofRow -> ((GroupInfo, GroupKeysRow), GroupMember) + toGroupAndMember currentTs c (groupInfoRow :. memberRow :. proofRow) = let groupInfo = toGroupInfo currentTs cxt userContactId [] groupInfoRow member = toGroupMember currentTs userContactId memberRow - in (groupInfo, (member :: GroupMember) {activeConn = Just c}) + in (groupInfo, (member :: GroupMember) {activeConn = Just c, memberBadgeProof = NoJSON $ maybeRowToBadgeProof proofRow}) getUserContact_ :: Int64 -> ExceptT StoreError IO UserContact getUserContact_ userContactLinkId = ExceptT $ do userContact_ diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 5ef9cbe723..35a5138ed1 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -24,7 +24,7 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Int (Int64) import Data.Time.Clock (getCurrentTime) -import Simplex.Chat.Badges (badgeToRow, verifyBadge_) +import Simplex.Chat.Badges (ProofPresHeader, badgeToRow) import Simplex.Chat.Protocol (MsgContent, businessChatsVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups @@ -57,6 +57,7 @@ createOrUpdateContactRequest :: Bool -> InvitationId -> VersionRangeChat -> + Maybe ProofPresHeader -> Profile -> Maybe XContactId -> Maybe SharedMsgId -> @@ -74,7 +75,8 @@ createOrUpdateContactRequest isSimplexTeam invId cReqChatVRange@(VersionRange minV maxV) - profile@Profile {displayName, fullName, shortDescr, description, image, contactLink, badge, preferences} + presHeader_ + profile@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} xContactId_ welcomeMsgId_ requestMsg_ @@ -94,8 +96,8 @@ createOrUpdateContactRequest Just (gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}}, keysData) -> do clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId cr <- liftIO $ getContactRequestByXContactId xContactId - gks <- mkGroupKeys db cxt gInfo keysData - pure $ RSAcceptedRequest cr (REBusinessChat (GIK gInfo gks) clientMember) + gks <- mkGroupInfoKeys db cxt gInfo keysData + pure $ RSAcceptedRequest cr (REBusinessChat gks 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 -> @@ -167,7 +169,7 @@ createOrUpdateContactRequest createContactRequest :: ExceptT StoreError IO RequestStage createContactRequest = do currentTs <- liftIO $ getCurrentTime - badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge + (Profile {badge}, badgeVerified) <- liftIO $ profileBadgeVerified presHeader_ (badgeKeys cxt) Nothing profile ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ DB.execute @@ -225,7 +227,7 @@ createOrUpdateContactRequest 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 = LocalProfile {displayName = oldDisplayName}} = do + updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = oldProfile@LocalProfile {displayName = oldDisplayName}} = do currentTs <- liftIO getCurrentTime liftIO $ updateProfile currentTs updateRequest currentTs @@ -234,7 +236,7 @@ createOrUpdateContactRequest pure $ RSCurrentRequest (Just ucr) ucr' re_ where updateProfile currentTs = do - badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge + (Profile {badge}, badgeVerified) <- profileBadgeVerified presHeader_ (badgeKeys cxt) (Just oldProfile) profile DB.execute db [sql| @@ -307,8 +309,8 @@ createOrUpdateContactRequest case gInfo of GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do clientMember <- getGroupMemberByMemberId db cxt user gInfo customerId - gks <- mkGroupKeys db cxt gInfo keysData - pure $ Just (REBusinessChat (GIK gInfo gks) clientMember) + gks <- mkGroupInfoKeys db cxt gInfo keysData + pure $ Just (REBusinessChat gks clientMember) _ -> throwError SEInvalidBusinessChatContactRequest (Nothing, Nothing) -> pure Nothing _ -> throwError $ SEInvalidContactRequestEntity contactRequestId diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 76029b4a4f..462b3629f1 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -108,7 +108,7 @@ import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Type.Equality -import Simplex.Chat.Badges (badgeToRow) +import Simplex.Chat.Badges (ProofPresHeader, badgeToRow) import Simplex.Chat.Messages import Simplex.Chat.Store.Shared import Simplex.Chat.Names (SimplexDomainClaim (..)) @@ -163,8 +163,8 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> Maybe PreparedChatEntity -> ConnReqContact -> ConnReqUriHash -> Maybe ShortLinkContact -> XContactId -> Maybe IncognitoProfile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO Connection -createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xContactId incognitoProfile groupLinkId subMode chatV pqSup = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> Maybe PreparedChatEntity -> ConnReqContact -> ProofPresHeader -> ConnReqUriHash -> Maybe ShortLinkContact -> XContactId -> Maybe IncognitoProfile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO Connection +createConnReqConnection db userId acId preparedEntity_ cReq presHeader cReqHash sLnk xContactId incognitoProfile groupLinkId subMode chatV pqSup = do currentTs <- getCurrentTime customUserProfileId <- forM incognitoProfile $ \case NewIncognito p -> createIncognitoProfile_ db userId currentTs p @@ -175,13 +175,13 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, - via_contact_uri, via_contact_uri_hash, via_short_link_contact, contact_id, group_member_id, + via_contact_uri, pres_header, via_contact_uri_hash, via_short_link_contact, contact_id, group_member_id, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connStatus, connType, BI True) - :. (cReq, cReqHash, sLnk, contactId_, groupMemberId_) + :. (cReq, presHeader, cReqHash, sLnk, contactId_, groupMemberId_) :. (xContactId, customUserProfileId, BI (isJust groupLinkId), groupLinkId) :. (currentTs, currentTs, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup) ) @@ -404,12 +404,12 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createPreparedContact :: DB.Connection -> StoreCxt -> User -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> Maybe Bool -> ExceptT StoreError IO Contact -createPreparedContact db cxt user p connLinkToConnect welcomeSharedMsgId verified_ = do +createPreparedContact :: DB.Connection -> StoreCxt -> User -> Maybe ProofPresHeader -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> Maybe Bool -> ExceptT StoreError IO Contact +createPreparedContact db cxt user presHeader_ p connLinkToConnect welcomeSharedMsgId verified_ = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p - ct <- getContact db cxt user =<< createContact_ db cxt user p ctUserPreferences prepared "" currentTs + ct <- getContact db cxt user =<< createContact_ db cxt user presHeader_ p ctUserPreferences prepared "" currentTs liftIO $ maybe (pure ct) (setContactDomainVerified db user ct) verified_ updatePreparedContactUser :: DB.Connection -> StoreCxt -> User -> Contact -> User -> ExceptT StoreError IO Contact @@ -450,11 +450,11 @@ updatePreparedContactUser safeDeleteLDN db user oldLDN getContact db cxt newUser contactId -createDirectContact :: DB.Connection -> StoreCxt -> User -> Connection -> Profile -> ExceptT StoreError IO Contact -createDirectContact db cxt user Connection {connId, localAlias} p = do +createDirectContact :: DB.Connection -> StoreCxt -> User -> Connection -> Maybe ProofPresHeader -> Profile -> ExceptT StoreError IO Contact +createDirectContact db cxt user Connection {connId, localAlias} presHeader_ p = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db cxt user p ctUserPreferences Nothing localAlias currentTs + contactId <- createContact_ db cxt user presHeader_ p ctUserPreferences Nothing localAlias currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) getContact db cxt user contactId @@ -562,23 +562,23 @@ deleteUnusedProfile_ db userId profileId = :. (userId, profileId, userId, profileId, profileId) ) -updateContactProfile :: DB.Connection -> StoreCxt -> User -> Contact -> Profile -> ExceptT StoreError IO Contact -updateContactProfile db cxt user@User {userId} c p' = do +updateContactProfile :: DB.Connection -> StoreCxt -> User -> Contact -> Maybe ProofPresHeader -> Profile -> ExceptT StoreError IO Contact +updateContactProfile db cxt user@User {userId} c presHeader_ p' = do currentTs <- liftIO getCurrentTime - badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) lp p' + (p'', badgeVerified) <- liftIO $ profileBadgeVerified presHeader_ (badgeKeys cxt) (Just lp) claimProfile let nameVerified = if claimChanged then Nothing else prevVerification profile = toLocalProfile profileId p'' localAlias currentTs badgeVerified nameVerified - updateContactProfile' currentTs badgeVerified profile + updateContactProfile' currentTs p'' badgeVerified profile where Contact {contactId, localDisplayName, profile = lp@LocalProfile {profileId, displayName, localAlias, contactDomain = prevClaim, contactDomainVerified = prevVerification}, userPreferences} = c Profile {displayName = newName, contactDomain, preferences} = p' mergedPreferences = contactUserPreferences user userPreferences preferences $ contactConnIncognito c claimChanged = (domain <$> prevClaim) /= (domain <$> contactDomain) - p'' = (p' :: Profile) {contactDomain = (\d -> d {proof = if claimChanged then Nothing else proof =<< prevClaim}) <$> contactDomain} + claimProfile = (p' :: Profile) {contactDomain = (\d -> d {proof = if claimChanged then Nothing else proof =<< prevClaim}) <$> contactDomain} clearVerificationIfClaimChanged = when claimChanged $ DB.execute db "UPDATE contact_profiles SET contact_domain_verified = NULL WHERE user_id = ? AND contact_profile_id = ?" (userId, profileId) - updateContactProfile' currentTs badgeVerified profile + updateContactProfile' currentTs p'' badgeVerified profile | displayName == newName = do liftIO $ updateContactProfile_' db userId profileId p'' badgeVerified currentTs liftIO clearVerificationIfClaimChanged diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a376ee7ca5..6f9ca5a068 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -193,6 +193,7 @@ module Simplex.Chat.Store.Groups resetMemberContactFields, updateMemberProfile, updateContactMemberProfile, + setMemberBadgeProof, getXGrpLinkMemReceived, setXGrpLinkMemReceived, createNewUnknownGroupMember, @@ -230,7 +231,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime) import Data.Text.Encoding (encodeUtf8) -import Simplex.Chat.Badges (BadgeRow, badgeToRow, verifyBadge_) +import Simplex.Chat.Badges (BadgeProof, BadgeRow, ProofPresHeader, acceptedBadge, badgeProofToRow, badgeToRow, sameBadgeProof, verifyBadge) import Simplex.Chat.Names (SimplexDomainClaim (..)) import Simplex.Chat.Messages import Simplex.Chat.Operators @@ -597,7 +598,8 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe supportChat = Nothing, memberPubKey, relayLink = Nothing, - memberVerifiedCode = Nothing + memberVerifiedCode = Nothing, + memberBadgeProof = NoJSON Nothing } where memberChatVRange@(VersionRange minV maxV) = vr @@ -677,7 +679,7 @@ createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile b randHostId <- liftIO $ encodedRandomBytes gVar 12 let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_host_" <> randHostId hostProfile = profileFromName $ nameFromBS randHostId - (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user hostProfile currentTs + (localDisplayName, profileId, _, _) <- createNewMemberProfile_ db cxt user hostProfile Nothing currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute @@ -834,7 +836,7 @@ updatePreparedUserAndHostMembers' |] (memberId, memberRole, membershipStatus, currentTs, groupMemberId' membership) updateHostMember currentTs = do - _ <- updateMemberProfile db cxt user hostMember fromMemberProfile + _ <- updateMemberProfile db cxt user hostMember Nothing fromMemberProfile let MemberIdRole memberId memberRole = fromMember gmId = groupMemberId' hostMember liftIO $ @@ -885,7 +887,7 @@ createGroupViaLink' (,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user hostMemberId where insertHost_ currentTs groupId = do - (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user fromMemberProfile currentTs + (localDisplayName, profileId, _, _) <- createNewMemberProfile_ db cxt user fromMemberProfile Nothing currentTs let MemberIdRole {memberId, memberRole} = fromMember indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do @@ -1412,7 +1414,8 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, supportChat = Nothing, memberPubKey = Nothing, relayLink = Nothing, - memberVerifiedCode = Nothing + memberVerifiedCode = Nothing, + memberBadgeProof = NoJSON Nothing } where insertMember_ = do @@ -1713,7 +1716,7 @@ createRelayForOwner :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> Gr createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do currentTs <- liftIO getCurrentTime let relayProfile = profileFromName displayName - (localDisplayName, memProfileId, _) <- createNewMemberProfile_ db cxt user relayProfile currentTs + (localDisplayName, memProfileId, _, _) <- createNewMemberProfile_ db cxt user relayProfile Nothing currentTs groupMemberId <- createWithRandomId' db gVar $ \memId -> runExceptT $ do indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ @@ -1752,7 +1755,7 @@ getCreateRelayForMember db cxt gVar user@User {userId, userContactId} GroupInfo randRelayId <- liftIO $ encodedRandomBytes gVar 12 let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_relay_" <> randRelayId relayProfile = profileFromName $ nameFromBS randRelayId - (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user relayProfile currentTs + (localDisplayName, profileId, _, _) <- createNewMemberProfile_ db cxt user relayProfile Nothing currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId groupMemberId <- liftIO $ do DB.execute @@ -1825,7 +1828,7 @@ setRelayLinkAccepted db cxt user m (MemberKey relayKey) profile = do WHERE group_member_id = ? |] (relayKey, currentTs, gmId) - void $ updateMemberProfile db cxt user m profile + void $ updateMemberProfile db cxt user m Nothing profile (,) <$> getGroupMemberById db cxt user gmId <*> getGroupRelayByGMId db gmId setRelayLinkConfId :: DB.Connection -> GroupMember -> ConfirmationId -> ShortLinkContact -> IO () @@ -1884,7 +1887,7 @@ updateRelayMemberData db cxt user m memberId (MemberKey relayKey) profile = do WHERE group_member_id = ? |] (memberId, relayKey, currentTs, groupMemberId' m) - void $ updateMemberProfile db cxt user m profile + void $ updateMemberProfile db cxt user m Nothing profile setGroupInProgressDone :: DB.Connection -> GroupInfo -> IO () setGroupInProgressDone db GroupInfo {groupId} = do @@ -1894,8 +1897,8 @@ setGroupInProgressDone db GroupInfo {groupId} = do "UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ?" (currentTs, groupId) -createRelayRequestGroup :: DB.Connection -> StoreCxt -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) -createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay memberStatus relayStatus = do +createRelayRequestGroup :: DB.Connection -> StoreCxt -> User -> GroupRelayInvitation -> Maybe ProofPresHeader -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink, publicGroupId, fromMemberKey} presHeader_ invId reqChatVRange initialDelay memberStatus relayStatus = do currentTs <- liftIO getCurrentTime -- Create group with placeholder profile let Profile {displayName = fromMemberLDN} = fromMemberProfile @@ -1927,17 +1930,18 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb UPDATE groups SET relay_request_inv_id = ?, relay_request_group_link = ?, + relay_request_public_group_id = ?, relay_request_peer_chat_min_version = ?, relay_request_peer_chat_max_version = ?, relay_request_delay = ?, relay_request_execute_at = ? WHERE group_id = ? |] - (Binary invId, groupLink, minVersion reqChatVRange, maxVersion reqChatVRange, initialDelay, currentTs, groupId) + (Binary invId, groupLink, publicGroupId, minVersion reqChatVRange, maxVersion reqChatVRange, initialDelay, currentTs, groupId) insertOwner_ currentTs groupId = do let MemberIdRole {memberId, memberRole} = fromMember VersionRange minV maxV = reqChatVRange - (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user fromMemberProfile currentTs + (localDisplayName, profileId, Profile {badge}, badgeVerified) <- createNewMemberProfile_ db cxt user fromMemberProfile presHeader_ currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute @@ -1946,14 +1950,16 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + peer_chat_min_version, peer_chat_max_version, member_pub_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, memberStatus, Binary B.empty) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) - :. (minV, maxV) + :. (minV, maxV, (\(MemberKey k) -> k) <$> fromMemberKey) ) - insertedRowId db + ownerMemberId <- insertedRowId db + forM_ (verifiedProof badgeVerified badge) $ createMemberBadgeProof db ownerMemberId + pure ownerMemberId updateRelayOwnStatusFromTo :: DB.Connection -> GroupInfo -> RelayStatus -> RelayStatus -> IO GroupInfo updateRelayOwnStatusFromTo db gInfo@GroupInfo {groupId} fromStatus toStatus = do @@ -2020,7 +2026,7 @@ getRelayServedGroups db cxt User {userId, userContactId} = do <> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?, ?)" ) (userId, userContactId, RSAccepted, RSAcknowledgedRoster, RSActive) - forM rows $ \(g, keysData) -> GIK g <$> mkGroupKeys db cxt g keysData + forM rows $ uncurry (mkGroupInfoKeys db cxt) getRelayPublishableGroups :: DB.Connection -> User -> IO [(Int64, B64UrlByteString, Maybe PublicGroupAccess)] getRelayPublishableGroups db User {userId, userContactId} = @@ -2066,7 +2072,7 @@ getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do ) (userId, userContactId, RSInactive, cutoffTs) -createJoiningMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) +createJoiningMember :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe ProofPresHeader -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> Maybe MemberKey -> ExceptT StoreError IO (GroupMemberId, MemberId) createJoiningMember db cxt @@ -2074,7 +2080,8 @@ createJoiningMember User {userId, userContactId} GroupInfo {groupId, membership} cReqChatVRange - Profile {displayName, fullName, shortDescr, description, image, contactLink, badge, preferences} + p@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} + presHeader_ cReqXContactId_ cReqMemberId_ welcomeMsgId_ @@ -2082,7 +2089,7 @@ createJoiningMember memberStatus memberKey_ = do currentTs <- liftIO getCurrentTime - badgeVerified <- liftIO $ verifyBadge_ (badgeKeys cxt) badge + (Profile {badge}, badgeVerified) <- liftIO $ profileBadgeVerified presHeader_ (badgeKeys cxt) Nothing p ExceptT . withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ DB.execute @@ -2090,7 +2097,7 @@ createJoiningMember "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" ((displayName, fullName, shortDescr, description, image, contactLink, userId, currentTs, currentTs) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- liftIO $ insertedRowId db - case cReqMemberId_ of + r@(groupMemberId, _) <- case cReqMemberId_ of Just memberId -> do checkMemberNotExists memberId insertMember_ ldn profileId memberId currentTs @@ -2101,6 +2108,8 @@ createJoiningMember insertMember_ ldn profileId (MemberId memId) currentTs groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) + liftIO $ forM_ (verifiedProof badgeVerified badge) $ createMemberBadgeProof db groupMemberId + pure r where VersionRange minV maxV = cReqChatVRange -- TODO [relays] relay: TBC communicate rejection @@ -2416,13 +2425,13 @@ increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, memb pure g {membersRequireAttention = membersRequireAttention + 1} -- | add new member with profile -createNewGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do +createNewGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> MemberInfo -> Maybe ProofPresHeader -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profile} presHeader_ memCategory memStatus = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId, badgeVerified) <- createNewMemberProfile_ db cxt user profile currentTs + (localDisplayName, memProfileId, memProfile, badgeVerified) <- createNewMemberProfile_ db cxt user profile presHeader_ currentTs let newMember = NewGroupMember - { memInfo, + { memInfo = (memInfo :: MemberInfo) {profile = memProfile}, memCategory, memStatus, memRestriction = Nothing, @@ -2434,16 +2443,16 @@ createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profil } createNewMember_ db user gInfo newMember badgeVerified currentTs -createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Maybe Bool) -createNewMemberProfile_ db cxt User {userId} Profile {displayName, fullName, shortDescr, description, image, contactLink, badge, preferences} createdAt = +createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> Maybe ProofPresHeader -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Profile, Maybe Bool) +createNewMemberProfile_ db cxt User {userId} p@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} presHeader_ createdAt = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do - badgeVerified <- verifyBadge_ (badgeKeys cxt) badge + (p'@Profile {badge}, badgeVerified) <- profileBadgeVerified presHeader_ (badgeKeys cxt) Nothing p DB.execute db "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" ((displayName, fullName, shortDescr, description, image, contactLink, userId, createdAt, createdAt) :. badgeToRow badge badgeVerified :. prefsToRow preferences) profileId <- insertedRowId db - pure $ Right (ldn, profileId, badgeVerified) + pure $ Right (ldn, profileId, p', badgeVerified) createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> Maybe Bool -> UTCTime -> ExceptT StoreError IO GroupMember createNewMember_ @@ -2451,7 +2460,7 @@ createNewMember_ User {userId, userContactId} GroupInfo {groupId} NewGroupMember - { memInfo = MemberInfo memberId memberRole memChatVRange memberProfile memKey, + { memInfo = MemberInfo memberId memberRole memChatVRange memberProfile@Profile {badge} memKey, memCategory = memberCategory, memStatus = memberStatus, memRestriction, @@ -2467,6 +2476,7 @@ createNewMember_ activeConn = Nothing memberChatVRange@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange memberPubKey = (\(MemberKey k) -> k) <$> memKey + memberProof = verifiedProof badgeVerified badge indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -2485,6 +2495,7 @@ createNewMember_ :. (minV, maxV) ) groupMemberId <- liftIO $ insertedRowId db + liftIO $ forM_ memberProof $ createMemberBadgeProof db groupMemberId pure GroupMember { groupMemberId, @@ -2509,7 +2520,8 @@ createNewMember_ supportChat = Nothing, memberPubKey, relayLink = Nothing, - memberVerifiedCode = Nothing + memberVerifiedCode = Nothing, + memberBadgeProof = NoJSON memberProof } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) @@ -2614,18 +2626,19 @@ getMemberRelationsVector db GroupMember {groupMemberId} = "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" (Only groupMemberId) -createIntroReMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> MemberInfo -> Maybe ProofPresHeader -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember createIntroReMember db cxt user gInfo memInfo@(MemberInfo _ _ _ memberProfile _) + presHeader_ memRestrictions_ = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId, badgeVerified) <- createNewMemberProfile_ db cxt user memberProfile currentTs + (localDisplayName, memProfileId, memberProfile', badgeVerified) <- createNewMemberProfile_ db cxt user memberProfile presHeader_ currentTs let memRestriction = restriction <$> memRestrictions_ - newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} + newMember = NewGroupMember {memInfo = (memInfo :: MemberInfo) {profile = memberProfile'}, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} createNewMember_ db user gInfo newMember badgeVerified currentTs createIntroReMemberConn :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> MemberInfo -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember @@ -3381,25 +3394,26 @@ setMemberContactStartedConnection db Contact {contactId} = do "UPDATE contacts SET grp_direct_inv_started_connection = ?, updated_at = ? WHERE contact_id = ?" (BI True, currentTs, contactId) -updateMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember -updateMemberProfile db cxt user m@GroupMember {memberContactId} p' = case memberContactId of - Nothing -> updateUnlinkedMemberProfile db cxt user m p' +updateMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Maybe ProofPresHeader -> Profile -> ExceptT StoreError IO GroupMember +updateMemberProfile db cxt user m@GroupMember {memberContactId} presHeader_ p = case memberContactId of + Nothing -> updateUnlinkedMemberProfile db cxt user m presHeader_ p Just ctId -> do ct <- getContact db cxt user ctId if contactUpdatableFromMember ct - then fst <$> updateContactMemberProfile db cxt user m ct p' - else pure m + then fst <$> updateContactMemberProfile db cxt user m ct presHeader_ p + else liftIO $ setMemberBadgeProof db cxt m presHeader_ p -updateUnlinkedMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember -updateUnlinkedMemberProfile db cxt user@User {userId} m p' = do +updateUnlinkedMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Maybe ProofPresHeader -> Profile -> ExceptT StoreError IO GroupMember +updateUnlinkedMemberProfile db cxt user@User {userId} m presHeader_ p = do currentTs <- liftIO getCurrentTime - badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' + (p', badgeVerified) <- liftIO $ profileBadgeVerified presHeader_ (badgeKeys cxt) (Just $ memberProfile m) p let memberProfile = toLocalProfile profileId p' localAlias currentTs badgeVerified Nothing - updateMemberProfile' currentTs badgeVerified memberProfile + m' <- updateMemberProfile' currentTs p' badgeVerified memberProfile + liftIO $ setMemberBadgeProof db cxt m' presHeader_ p where GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m - Profile {displayName = newName} = p' - updateMemberProfile' currentTs badgeVerified memberProfile + Profile {displayName = newName} = p + updateMemberProfile' currentTs p' badgeVerified memberProfile | displayName == newName = do liftIO $ updateMemberContactProfileReset_' db userId profileId p' badgeVerified currentTs pure m {memberProfile} @@ -3413,16 +3427,17 @@ updateUnlinkedMemberProfile db cxt user@User {userId} m p' = do safeDeleteLDN db user localDisplayName pure $ Right m {localDisplayName = ldn, memberProfile} -updateContactMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Contact -> Profile -> ExceptT StoreError IO (GroupMember, Contact) -updateContactMemberProfile db cxt user@User {userId} m ct@Contact {contactId} p' = do +updateContactMemberProfile :: DB.Connection -> StoreCxt -> User -> GroupMember -> Contact -> Maybe ProofPresHeader -> Profile -> ExceptT StoreError IO (GroupMember, Contact) +updateContactMemberProfile db cxt user@User {userId} m ct@Contact {contactId} presHeader_ p = do currentTs <- liftIO getCurrentTime - badgeVerified <- liftIO $ profileBadgeVerified (badgeKeys cxt) (memberProfile m) p' + (p', badgeVerified) <- liftIO $ profileBadgeVerified presHeader_ (badgeKeys cxt) (Just $ memberProfile m) p let profile = toLocalProfile profileId p' localAlias currentTs badgeVerified Nothing - updateContactMemberProfile' currentTs badgeVerified profile + (m', ct') <- updateContactMemberProfile' currentTs p' badgeVerified profile + (,ct') <$> liftIO (setMemberBadgeProof db cxt m' presHeader_ p) where GroupMember {localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m - Profile {displayName = newName} = p' - updateContactMemberProfile' currentTs badgeVerified profile + Profile {displayName = newName} = p + updateContactMemberProfile' currentTs p' badgeVerified profile | displayName == newName = do liftIO $ updateMemberContactProfile_' db userId profileId p' badgeVerified currentTs pure (m {memberProfile = profile}, ct {profile} :: Contact) @@ -3432,6 +3447,37 @@ updateContactMemberProfile db cxt user@User {userId} m ct@Contact {contactId} p' updateContactLDN_ db user contactId localDisplayName ldn currentTs pure $ Right (m {localDisplayName = ldn, memberProfile = profile}, ct {localDisplayName = ldn, profile} :: Contact) +setMemberBadgeProof :: DB.Connection -> StoreCxt -> GroupMember -> Maybe ProofPresHeader -> Profile -> IO GroupMember +setMemberBadgeProof db cxt m@GroupMember {groupMemberId, memberBadgeProof = NoJSON storedProof} presHeader_ Profile {badge} = case acceptedBadge presHeader_ storedProof badge of + badge' | sameBadgeProof badge' storedProof -> pure m + Nothing -> m {memberBadgeProof = NoJSON Nothing} <$ DB.execute db "DELETE FROM group_member_badge_proofs WHERE group_member_id = ?" (Only groupMemberId) + Just b -> + verifyBadge (badgeKeys cxt) b >>= \case + Just True -> m {memberBadgeProof = NoJSON (Just b)} <$ createMemberBadgeProof db groupMemberId b + _ -> pure m + +verifiedProof :: Maybe Bool -> Maybe BadgeProof -> Maybe BadgeProof +verifiedProof badgeVerified = mfilter (const $ badgeVerified == Just True) + +createMemberBadgeProof :: DB.Connection -> GroupMemberId -> BadgeProof -> IO () +createMemberBadgeProof db groupMemberId badge = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO group_member_badge_proofs (group_member_id, badge_proof, badge_pres_header, badge_key_idx, badge_type, badge_expiry, badge_extra, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT (group_member_id) DO UPDATE SET + badge_proof = excluded.badge_proof, + badge_pres_header = excluded.badge_pres_header, + badge_key_idx = excluded.badge_key_idx, + badge_type = excluded.badge_type, + badge_expiry = excluded.badge_expiry, + badge_extra = excluded.badge_extra, + updated_at = excluded.updated_at + |] + (Only groupMemberId :. badgeProofToRow badge :. (currentTs, currentTs)) + getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool getXGrpLinkMemReceived db mId = ExceptT . firstRow fromOnlyBI (SEGroupMemberNotFound mId) $ @@ -3449,7 +3495,7 @@ createNewUnknownGroupMember :: DB.Connection -> StoreCxt -> User -> GroupInfo -> createNewUnknownGroupMember db cxt user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName - (localDisplayName, profileId, _) <- createNewMemberProfile_ db cxt user memberProfile currentTs + (localDisplayName, profileId, _, _) <- createNewMemberProfile_ db cxt user memberProfile Nothing currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ DB.execute @@ -3494,7 +3540,7 @@ createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupI where VersionRange minV maxV = vr cxt newOwnerProfile currentTs = do - (ldn, pId, _) <- createNewMemberProfile_ db cxt user (profileFromName $ nameFromMemberId memberId) currentTs + (ldn, pId, _, _) <- createNewMemberProfile_ db cxt user (profileFromName $ nameFromMemberId memberId) Nothing currentTs pure (ldn, pId) contactNameAndProfile ctId = do Contact {localDisplayName = ldn, profile = LocalProfile {profileId = pId}} <- getContact db cxt user ctId @@ -3503,9 +3549,9 @@ createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupI -- Intro refreshes only profile / status / peer version. Role and key stay owner-authoritative -- (the owner-signed roster for members/moderators/admins, link data for owners), so taking either from -- an in-band relayed intro would let a compromised relay substitute them. -updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember -updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} = do - _ <- updateMemberProfile db cxt user member profile +updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> Maybe ProofPresHeader -> ExceptT StoreError IO GroupMember +updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} presHeader_ = do + _ <- updateMemberProfile db cxt user member presHeader_ profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -3523,9 +3569,9 @@ updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupM where VersionRange minV maxV = maybe memberChatVRange fromChatVRange v -updateUnknownMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} status = do - _ <- updateMemberProfile db cxt user unknownMember profile +updateUnknownMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> Maybe ProofPresHeader -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile, memberKey} presHeader_ status = do + _ <- updateMemberProfile db cxt user unknownMember presHeader_ profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute @@ -3552,9 +3598,9 @@ updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMem -- Like updateUnknownMemberAnnounced but preserves member_role and member_pub_key -- (roster-established for moderators/admins; the dissemination carries only the profile). -updateRosterMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -updateRosterMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} status = do - _ <- updateMemberProfile db cxt user unknownMember profile +updateRosterMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> Maybe ProofPresHeader -> GroupMemberStatus -> ExceptT StoreError IO GroupMember +updateRosterMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} presHeader_ status = do + _ <- updateMemberProfile db cxt user unknownMember presHeader_ profile currentTs <- liftIO getCurrentTime liftIO $ DB.execute diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 24ed45c9a9..36f594560a 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -53,6 +53,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json +import Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -105,7 +106,8 @@ schemaMigrations = ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), - ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json) + ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), + ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_bindings.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_bindings.hs new file mode 100644 index 0000000000..596c557c99 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_bindings.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260925_badge_bindings :: Text +m20260925_badge_bindings = + [r| +ALTER TABLE connections ADD COLUMN pres_header BYTEA; + +ALTER TABLE groups ADD COLUMN relay_request_public_group_id BYTEA; + +CREATE TABLE group_member_badge_proofs( + group_member_id BIGINT PRIMARY KEY REFERENCES group_members ON DELETE CASCADE, + badge_proof BYTEA NOT NULL, + badge_pres_header BYTEA NOT NULL, + badge_key_idx BIGINT NOT NULL, + badge_type TEXT NOT NULL, + badge_expiry TIMESTAMPTZ NOT NULL, + badge_extra TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +|] + +down_m20260925_badge_bindings :: Text +down_m20260925_badge_bindings = + [r| +DROP TABLE group_member_badge_proofs; + +ALTER TABLE groups DROP COLUMN relay_request_public_group_id; + +ALTER TABLE connections DROP COLUMN pres_header; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index ad975874db..9c7ab5aa32 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -618,7 +618,8 @@ CREATE TABLE test_chat_schema.connections ( short_link_inv bytea, via_short_link_contact bytea, via_contact_uri bytea, - relay_test smallint DEFAULT 0 NOT NULL + relay_test smallint DEFAULT 0 NOT NULL, + pres_header bytea ); @@ -944,6 +945,20 @@ ALTER TABLE test_chat_schema.files ALTER COLUMN file_id ADD GENERATED ALWAYS AS +CREATE TABLE test_chat_schema.group_member_badge_proofs ( + group_member_id bigint NOT NULL, + badge_proof bytea NOT NULL, + badge_pres_header bytea NOT NULL, + badge_key_idx bigint NOT NULL, + badge_type text NOT NULL, + badge_expiry timestamp with time zone NOT NULL, + badge_extra text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + + CREATE TABLE test_chat_schema.group_member_intros ( group_member_intro_id bigint NOT NULL, re_group_member_id bigint NOT NULL, @@ -1170,7 +1185,8 @@ CREATE TABLE test_chat_schema.groups ( roster_blob bytea, group_domain_verified smallint, stored_roster_version bigint, - applied_complete_roster_version bigint + applied_complete_roster_version bigint, + relay_request_public_group_id bytea ); @@ -1938,6 +1954,11 @@ ALTER TABLE ONLY test_chat_schema.files +ALTER TABLE ONLY test_chat_schema.group_member_badge_proofs + ADD CONSTRAINT group_member_badge_proofs_pkey PRIMARY KEY (group_member_id); + + + ALTER TABLE ONLY test_chat_schema.group_member_intros ADD CONSTRAINT group_member_intros_pkey PRIMARY KEY (group_member_intro_id); @@ -3404,6 +3425,11 @@ ALTER TABLE ONLY test_chat_schema.users +ALTER TABLE ONLY test_chat_schema.group_member_badge_proofs + ADD CONSTRAINT group_member_badge_proofs_group_member_id_fkey FOREIGN KEY (group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.group_member_intros ADD CONSTRAINT group_member_intros_re_group_member_id_fkey FOREIGN KEY (re_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/RelayRequests.hs b/src/Simplex/Chat/Store/RelayRequests.hs index 2e590a1696..b7c081ffa6 100644 --- a/src/Simplex/Chat/Store/RelayRequests.hs +++ b/src/Simplex/Chat/Store/RelayRequests.hs @@ -77,7 +77,7 @@ getNextPendingRelayRequest db = db [sql| SELECT - relay_request_inv_id, relay_request_group_link, + relay_request_inv_id, relay_request_group_link, relay_request_public_group_id, relay_request_peer_chat_min_version, relay_request_peer_chat_max_version, relay_request_delay, relay_request_retries, created_at, relay_request_execute_at FROM groups @@ -85,10 +85,10 @@ getNextPendingRelayRequest db = |] (Only groupId) where - toRelayRequestData :: (Maybe InvitationId, Maybe ShortLinkContact, Maybe VersionChat, Maybe VersionChat, Int64, Int, UTCTime, UTCTime) -> Either StoreError (GroupId, RelayRequestData) + toRelayRequestData :: (Maybe InvitationId, Maybe ShortLinkContact, Maybe B64UrlByteString, Maybe VersionChat, Maybe VersionChat, Int64, Int, UTCTime, UTCTime) -> Either StoreError (GroupId, RelayRequestData) toRelayRequestData = \case - (Just relayInvId, Just reqGroupLink, Just minV, Just maxV, reqDelay, reqRetries, reqCreatedAt, reqExecuteAt) -> - Right (groupId, RelayRequestData {relayInvId, reqGroupLink, reqChatVRange = fromMaybe (versionToRange maxV) $ safeVersionRange minV maxV, reqDelay, reqRetries, reqCreatedAt, reqExecuteAt}) + (Just relayInvId, Just reqGroupLink, reqPublicGroupId, Just minV, Just maxV, reqDelay, reqRetries, reqCreatedAt, reqExecuteAt) -> + Right (groupId, RelayRequestData {relayInvId, reqGroupLink, reqPublicGroupId, reqChatVRange = fromMaybe (versionToRange maxV) $ safeVersionRange minV maxV, reqDelay, reqRetries, reqCreatedAt, reqExecuteAt}) _ -> Left $ SEInternalError "missing relay request data" updateRelayRequestRetries :: DB.Connection -> GroupId -> Int64 -> UTCTime -> IO () diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 4dcefa1598..f16351f6c2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -176,6 +176,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json +import Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -351,7 +352,8 @@ schemaMigrations = ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), - ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json) + ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), + ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_bindings.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_bindings.hs new file mode 100644 index 0000000000..fdb4d86910 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_bindings.hs @@ -0,0 +1,36 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260925_badge_bindings :: Query +m20260925_badge_bindings = + [sql| +ALTER TABLE connections ADD COLUMN pres_header BLOB; + +ALTER TABLE groups ADD COLUMN relay_request_public_group_id BLOB; + +CREATE TABLE group_member_badge_proofs( + group_member_id INTEGER PRIMARY KEY REFERENCES group_members ON DELETE CASCADE, + badge_proof BLOB NOT NULL, + badge_pres_header BLOB NOT NULL, + badge_key_idx INTEGER NOT NULL, + badge_type TEXT NOT NULL, + badge_expiry TEXT NOT NULL, + badge_extra TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +|] + +down_m20260925_badge_bindings :: Query +down_m20260925_badge_bindings = + [sql| +DROP TABLE group_member_badge_proofs; + +ALTER TABLE groups DROP COLUMN relay_request_public_group_id; + +ALTER TABLE connections DROP COLUMN pres_header; +|] 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 00886165b5..d12b3c98cf 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1393,7 +1393,7 @@ SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) Query: SELECT conn_id FROM rcv_queues WHERE switch_status = ? AND deleted = 0 Plan: -SCAN rcv_queues +SEARCH rcv_queues USING INDEX idx_rcv_queues_switch_status (switch_status=?) Query: SELECT conn_id, rc_verify_code_ad, rc_verify_code_pq, CASE WHEN rc_verify_code_ad IS NULL THEN ratchet_state END FROM ratchets WHERE conn_id = ? Plan: 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 7b76c06527..535ca23fca 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -30,6 +30,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -84,6 +85,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -169,13 +171,15 @@ Query: 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.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, 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, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_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, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra FROM group_members m 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 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 group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) @@ -186,6 +190,7 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) 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=?) +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT delivery_task_id @@ -292,6 +297,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -328,6 +334,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -364,6 +371,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -584,10 +592,11 @@ Query: INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + peer_chat_min_version, peer_chat_max_version, member_pub_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -623,6 +632,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -659,6 +669,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -695,6 +706,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -762,7 +774,7 @@ SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT - relay_request_inv_id, relay_request_group_link, + relay_request_inv_id, relay_request_group_link, relay_request_public_group_id, relay_request_peer_chat_min_version, relay_request_peer_chat_max_version, relay_request_delay, relay_request_retries, created_at, relay_request_execute_at FROM groups @@ -1267,6 +1279,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1304,6 +1317,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1897,6 +1911,7 @@ Query: UPDATE groups SET relay_request_inv_id = ?, relay_request_group_link = ?, + relay_request_public_group_id = ?, relay_request_peer_chat_min_version = ?, relay_request_peer_chat_max_version = ?, relay_request_delay = ?, @@ -1957,6 +1972,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1993,6 +2009,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -5010,10 +5027,10 @@ Plan: Query: INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, - via_contact_uri, via_contact_uri_hash, via_short_link_contact, contact_id, group_member_id, + via_contact_uri, pres_header, via_contact_uri_hash, via_short_link_contact, contact_id, group_member_id, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -5068,6 +5085,20 @@ Query: Plan: +Query: + INSERT INTO group_member_badge_proofs (group_member_id, badge_proof, badge_pres_header, badge_key_idx, badge_type, badge_expiry, badge_extra, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT (group_member_id) DO UPDATE SET + badge_proof = excluded.badge_proof, + badge_pres_header = excluded.badge_pres_header, + badge_key_idx = excluded.badge_key_idx, + badge_type = excluded.badge_type, + badge_expiry = excluded.badge_expiry, + badge_extra = excluded.badge_extra, + updated_at = excluded.updated_at + +Plan: + Query: INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, chat_ts, created_at, updated_at, delivery_status) @@ -5883,10 +5914,12 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_status IN (?, ?, ?, ?) @@ -5900,6 +5933,7 @@ LIST SUBQUERY 1 SCAN chat_items USING COVERING INDEX idx_chat_items_group_member_id SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -5911,16 +5945,19 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id JOIN group_member_status_predicates sp ON m.member_status = sp.member_status WHERE m.group_id = ? AND m.relay_link = ? AND sp.current_member = 1 Plan: SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) SEARCH sp USING INDEX sqlite_autoindex_group_member_status_predicates_1 (member_status=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -5932,15 +5969,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ? Plan: SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -5952,15 +5992,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.group_id = ? AND m.index_in_group = ? Plan: SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -5972,15 +6015,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?) Plan: SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -5992,15 +6038,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.group_id = ? AND m.member_category = ? Plan: SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -6012,15 +6061,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.group_id = ? AND m.member_id = ? Plan: SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -6032,15 +6084,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.group_member_id = ? AND m.user_id = ? Plan: SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -6052,15 +6107,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.group_member_id = ? AND m.user_id = ? AND m.member_status NOT IN (?,?,?,?) Plan: SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -6072,15 +6130,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.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 != ?) Plan: SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -6092,15 +6153,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.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_role = ? Plan: SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -6112,15 +6176,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.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_role IN (?,?) Plan: SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -6132,15 +6199,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.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_role IN (?,?,?) Plan: SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -6152,15 +6222,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ? Plan: SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT @@ -6172,15 +6245,18 @@ Query: 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id WHERE m.user_id = ? AND m.removed_at < ? Plan: SEARCH m USING INDEX idx_group_members_user_id (user_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT f.file_id, f.ci_file_status, f.file_path @@ -6898,9 +6974,14 @@ SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?) SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?) +Query: DELETE FROM group_member_badge_proofs WHERE group_member_id = ? +Plan: +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) + Query: DELETE FROM group_members WHERE member_id = ? Plan: SCAN group_members +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -6932,6 +7013,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ? Plan: SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -6963,6 +7045,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: DELETE FROM group_members WHERE user_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_member_badge_proofs USING INTEGER PRIMARY KEY (rowid=?) SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -7345,6 +7428,10 @@ Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_i Plan: SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_conditions_commit (conditions_commit=? AND server_operator_id=?) +Query: SELECT agent_conn_id FROM connections +Plan: +SCAN connections USING COVERING INDEX sqlite_autoindex_connections_1 + Query: SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1 Plan: SEARCH connections USING INDEX idx_connections_conn_req_inv (user_id=? AND conn_req_inv=?) @@ -7808,7 +7895,7 @@ Query: SELECT user_id FROM users WHERE local_display_name = ? Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) -Query: SELECT via_contact_uri FROM connections WHERE connection_id = ? +Query: SELECT via_contact_uri, pres_header FROM connections WHERE connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) @@ -8056,6 +8143,10 @@ Query: UPDATE group_members SET member_profile_id = ?, updated_at = ? WHERE grou Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_members SET member_pub_key = ? WHERE member_category = 'user' +Plan: +SCAN group_members + Query: UPDATE group_members SET member_pub_key = ?, member_role = ?, updated_at = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -8112,6 +8203,10 @@ Query: UPDATE group_profiles SET description = ? WHERE display_name = ? Plan: SCAN group_profiles +Query: UPDATE group_profiles SET public_group_id = ? +Plan: +SCAN group_profiles + Query: UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ? Plan: SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) @@ -8168,6 +8263,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 member_priv_key = ? +Plan: +SCAN groups + Query: UPDATE groups SET member_priv_key = NULL Plan: SCAN groups @@ -8272,6 +8371,10 @@ Query: UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ? Plan: SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE user_contact_links SET short_link_contact = NULL +Plan: +SCAN user_contact_links + Query: UPDATE users SET active_user = 0 Plan: SCAN users diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 995497d5ee..95a837f5e1 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -211,7 +211,8 @@ CREATE TABLE groups( roster_blob BLOB, group_domain_verified INTEGER, stored_roster_version INTEGER, - applied_complete_roster_version INTEGER, -- received + applied_complete_roster_version INTEGER, + relay_request_public_group_id BLOB, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -390,6 +391,7 @@ CREATE TABLE connections( via_short_link_contact BLOB, via_contact_uri BLOB, relay_test INTEGER NOT NULL DEFAULT 0, + pres_header BLOB, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -981,6 +983,17 @@ CREATE TABLE badge_code_redemptions( created_at TEXT NOT NULL, UNIQUE(user_id, code) ) STRICT; +CREATE TABLE group_member_badge_proofs( + group_member_id INTEGER PRIMARY KEY REFERENCES group_members ON DELETE CASCADE, + badge_proof BLOB NOT NULL, + badge_pres_header BLOB NOT NULL, + badge_key_idx INTEGER NOT NULL, + badge_type TEXT NOT NULL, + badge_expiry TEXT NOT NULL, + badge_extra TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 908d5a823b..605d441eb4 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -32,7 +32,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Type.Equality -import Simplex.Chat.Badges (BadgeRow, badgeToRow, rowToBadge, verifyBadge_) +import Simplex.Chat.Badges (BadgeRow, MaybeBadgeProofRow, ProofPresHeader, badgeToRow, maybeRowToBadgeProof, rowToBadge) import Simplex.Chat.Names (SimplexDomainProof, SimplexDomainClaim (..), claimDomain) import Simplex.Chat.Messages import Simplex.Chat.Remote.Types @@ -414,12 +414,12 @@ setCommandConnId db User {userId} cmdId connId = do createContact :: DB.Connection -> StoreCxt -> User -> Profile -> ExceptT StoreError IO () createContact db cxt user profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db cxt user profile emptyChatPrefs Nothing "" currentTs + void $ createContact_ db cxt user Nothing profile emptyChatPrefs Nothing "" currentTs -createContact_ :: DB.Connection -> StoreCxt -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId -createContact_ db cxt User {userId} Profile {displayName, fullName, shortDescr, description, image, contactLink, contactDomain, peerType, badge, preferences} ctUserPreferences prepared localAlias currentTs = +createContact_ :: DB.Connection -> StoreCxt -> User -> Maybe ProofPresHeader -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId +createContact_ db cxt User {userId} presHeader_ p@Profile {displayName, fullName, shortDescr, description, image, contactLink, contactDomain, peerType, preferences} ctUserPreferences prepared localAlias currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do - badgeVerified <- verifyBadge_ (badgeKeys cxt) badge + (Profile {badge}, badgeVerified) <- profileBadgeVerified presHeader_ (badgeKeys cxt) Nothing p DB.execute db "INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" @@ -589,12 +589,12 @@ getConnReqInv db connId = "SELECT conn_req_inv FROM connections WHERE connection_id = ?" (Only connId) -getConnReqContact :: DB.Connection -> Int64 -> ExceptT StoreError IO ConnReqContact +getConnReqContact :: DB.Connection -> Int64 -> ExceptT StoreError IO (ConnReqContact, Maybe ProofPresHeader) getConnReqContact db connId = - ExceptT . firstRow fromOnly (SEConnectionNotFoundById connId) $ + ExceptT . firstRow id (SEConnectionNotFoundById connId) $ DB.query db - "SELECT via_contact_uri FROM connections WHERE connection_id = ?" + "SELECT via_contact_uri, pres_header FROM connections WHERE connection_id = ?" (Only connId) -- | Saves unique local display name based on passed displayName, suffixed with _N if required. @@ -747,14 +747,15 @@ toPublicGroupAccess (groupWebPage, groupDomain_, domainWebPage_, allowEmbedding_ domainWebPage = maybe False unBI domainWebPage_ allowEmbedding = maybe False unBI allowEmbedding_ -mkGroupKeys :: DB.Connection -> StoreCxt -> GroupInfo -> GroupKeysRow -> ExceptT StoreError IO GroupKeys -mkGroupKeys db cxt g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup}, membership} (rootPrivKey, rootPubKey, memberPrivKey_) = do +mkGroupInfoKeys :: DB.Connection -> StoreCxt -> GroupInfo -> GroupKeysRow -> ExceptT StoreError IO GroupInfoKeys +mkGroupInfoKeys db cxt g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup}, membership} (rootPrivKey, rootPubKey, memberPrivKey_) = do memberPrivKey <- case memberPrivKey_ of Just k -> pure k Nothing -> do (_, k) <- atomically $ C.generateKeyPair (drg cxt) setUserMemberKey db groupId (groupMemberId' membership) k - pure $ case (useRelays' g, isJust publicGroup, GRKPrivate <$> rootPrivKey <|> GRKPublic <$> rootPubKey) of + let membership' = membership {memberPubKey = Just $ C.publicKey memberPrivKey} :: GroupMember + pure $ GIK g {membership = membership'} $ case (useRelays' g, isJust publicGroup, GRKPrivate <$> rootPrivKey <|> GRKPublic <$> rootPubKey) of (False, _, _) -> GKGroup {memberPrivKey} (True, True, Just groupRootKey) -> GKPublicGroup {groupRootKey, memberPrivKey} (True, True, Nothing) -> GKPreparedPublicGroup {memberPrivKey} @@ -785,6 +786,7 @@ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing memberVerifiedCode = SecurityCode <$> memberCode_ <*> memberCodeVerifiedAt_ + memberBadgeProof = NoJSON Nothing memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer supportChat = case supportChatTs_ of Just chatTs -> @@ -811,15 +813,17 @@ groupMemberQuery = 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 + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id |] -toContactMember :: UTCTime -> StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember now cxt User {userContactId} (memberRow :. connRow) = - (toGroupMember now userContactId memberRow) {activeConn = toMaybeConnection cxt connRow} +toContactMember :: UTCTime -> StoreCxt -> User -> (GroupMemberRow :. MaybeConnectionRow :. MaybeBadgeProofRow) -> GroupMember +toContactMember now cxt User {userContactId} (memberRow :. connRow :. proofRow) = + (toGroupMember now userContactId memberRow) {activeConn = toMaybeConnection cxt connRow, memberBadgeProof = NoJSON $ maybeRowToBadgeProof proofRow} rowToLocalProfile :: UTCTime -> ProfileRow -> LocalProfile rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, encodedPrefs, receivedPrefs) :. badgeRow :. domainRow) = @@ -938,11 +942,7 @@ addGroupChatTags db g@GroupInfo {groupId} = do pure (g :: GroupInfo) {chatTags} getGroupInfoKeys :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfoKeys -getGroupInfoKeys db cxt user groupId = do - (g@GroupInfo {membership}, keysData) <- getGroupInfoRow db cxt user groupId - gks <- mkGroupKeys db cxt g keysData - let membership' = membership {memberPubKey = Just $ C.publicKey $ memberPrivKey gks} :: GroupMember - pure $ GIK (g :: GroupInfo) {membership = membership'} gks +getGroupInfoKeys db cxt user groupId = uncurry (mkGroupInfoKeys db cxt) =<< getGroupInfoRow db cxt user groupId getGroupInfo :: DB.Connection -> StoreCxt -> User -> Int64 -> ExceptT StoreError IO GroupInfo getGroupInfo db cxt user groupId = fst <$> getGroupInfoRow db cxt user groupId diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index cea169a4a4..81ed6ac5a8 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -54,7 +54,7 @@ import Data.Time.Clock (UTCTime) import Data.Type.Equality (testEquality, (:~:) (Refl)) import Data.Typeable (Typeable) import Data.Word (Word16) -import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), localBadgeInfo, localBadgeStatus, mkBadgeStatus, verifyBadge) +import Simplex.Chat.Badges (BadgeInfo (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), ProofPresHeader, acceptedBadge, localBadgeInfo, localBadgeStatus, mkBadgeStatus, verifyBadge) import Simplex.Chat.Names (SimplexDomainClaim (..)) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Chat.Types.Preferences @@ -855,15 +855,18 @@ fromLocalProfile LocalProfile {displayName, fullName, shortDescr, description, i OwnBadge _ _ -> Nothing -- the own credential is not sent, proof is generated on send ShownBadge _ _ -> Nothing -- a display-only badge is not sent -profileBadgeVerified :: Map Int BBSPublicKey -> LocalProfile -> Profile -> IO (Maybe Bool) -profileBadgeVerified keys LocalProfile {localBadge} Profile {badge = newBadge} = - case (localBadge, newBadge) of +profileBadgeVerified :: Maybe ProofPresHeader -> Map Int BBSPublicKey -> Maybe LocalProfile -> Profile -> IO (Profile, Maybe Bool) +profileBadgeVerified expected keys lp_ p@Profile {badge = rcvBadge} = + (,) p {badge = newBadge} <$> case (storedBadge, newBadge) of (_, Nothing) -> pure (Just False) -- an unchanged badge that verified before stays verified; failed or unknown-key badges -- are re-verified, so an unknown key heals once an app update adds it (Just lb, Just (BadgeProof _ _ _ newInfo)) | localBadgeInfo lb == newInfo && localBadgeStatus lb `notElem` [BSFailed, BSUnknownKey] -> pure (Just True) (_, Just newB) -> verifyBadge keys newB + where + storedBadge = (\LocalProfile {localBadge} -> localBadge) =<< lp_ + newBadge = acceptedBadge expected ((\Profile {badge} -> badge) . fromLocalProfile =<< lp_) rcvBadge -- a failed or unknown-key badge is re-verified on the next profile update even when its disclosed content -- is unchanged, so it heals once an app update adds the issuer key @@ -1007,7 +1010,9 @@ data GroupRelayInvitation = GroupRelayInvitation { fromMember :: MemberIdRole, fromMemberProfile :: Profile, relayMemberId :: MemberId, - groupLink :: ShortLinkContact + groupLink :: ShortLinkContact, + publicGroupId :: Maybe B64UrlByteString, + fromMemberKey :: Maybe MemberKey } deriving (Eq, Show) @@ -1210,13 +1215,15 @@ data GroupMember = GroupMember relayLink :: Maybe ShortLinkContact, -- out-of-band verified security code for connectionless (channel) members; -- regular members carry it in activeConn instead (see memberSecurityCode) - memberVerifiedCode :: Maybe SecurityCode + memberVerifiedCode :: Maybe SecurityCode, + memberBadgeProof :: NoJSON BadgeProof } deriving (Eq, Show) data RelayRequestData = RelayRequestData { relayInvId :: InvitationId, reqGroupLink :: ShortLinkContact, + reqPublicGroupId :: Maybe B64UrlByteString, reqChatVRange :: VersionRangeChat, reqDelay :: Int64, reqRetries :: Int, diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 7445a9c2d5..ae257b5025 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -151,20 +151,22 @@ setPreference_ f pref_ prefs = SCFCalls -> prefs {calls = pref_} SCFSessions -> prefs {sessions = pref_} -newtype PrefsJSON = PrefsJSON {unPrefsJSON :: Maybe Object} +newtype NoJSON a = NoJSON {unNoJSON :: Maybe a} deriving (Eq, Show) -instance ToJSON PrefsJSON where +instance ToJSON (NoJSON a) where toJSON _ = Null toEncoding _ = JE.null_ omitField _ = True -instance FromJSON PrefsJSON where - parseJSON _ = pure $ PrefsJSON Nothing - omittedField = Just $ PrefsJSON Nothing +instance FromJSON (NoJSON a) where + parseJSON _ = pure $ NoJSON Nothing + omittedField = Just $ NoJSON Nothing + +type PrefsJSON = NoJSON Object keepPrefsJSON :: (ToJSON p, HasField "_json" p PrefsJSON) => Value -> p -> p -keepPrefsJSON v ps = setField @"_json" ps . PrefsJSON $ case v of +keepPrefsJSON v ps = setField @"_json" ps . NoJSON $ case v of Object o | v /= toJSON ps -> Just o _ -> Nothing @@ -177,7 +179,7 @@ prefsFromRow_ prefsP encodedPrefs receivedPrefs = (decode =<< receivedPrefs) <|> decode = decodePrefs prefsP prefsToRow :: HasField "_json" p PrefsJSON => Maybe p -> (Maybe p, Maybe Text) -prefsToRow ps = (ps, encodeJSON . Object <$> (unPrefsJSON . getField @"_json" =<< ps)) +prefsToRow ps = (ps, encodeJSON . Object <$> (unNoJSON . getField @"_json" =<< ps)) -- collection of optional chat preferences for the user and the contact data Preferences = Preferences @@ -545,7 +547,7 @@ toChatPrefs FullPreferences {timedMessages, fullDelete, reactions, voice, files, calls = Just calls, sessions = Just sessions, commands = Just cmds, - _json = PrefsJSON Nothing + _json = NoJSON Nothing } defaultChatPrefs :: FullPreferences @@ -562,7 +564,7 @@ defaultChatPrefs = } emptyChatPrefs :: Preferences -emptyChatPrefs = Preferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing (PrefsJSON Nothing) +emptyChatPrefs = Preferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing (NoJSON Nothing) defaultGroupPrefs :: FullGroupPreferences defaultGroupPrefs = @@ -584,7 +586,7 @@ defaultGroupPrefs = } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing (PrefsJSON Nothing) +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing (NoJSON Nothing) businessGroupPrefs :: Preferences -> GroupPreferences businessGroupPrefs Preferences {timedMessages, fullDelete, reactions, voice, files, sessions, commands} = @@ -620,7 +622,7 @@ defaultBusinessGroupPrefs = comments = Just $ CommentsGroupPreference FEOff Nothing, signMessages = Just $ SignMessagesGroupPreference FEOff, commands = Nothing, - _json = PrefsJSON Nothing + _json = NoJSON Nothing } data TimedMessagesPreference = TimedMessagesPreference @@ -1133,7 +1135,7 @@ toGroupPreferences groupPreferences@FullGroupPreferences {commands = ListDef cmd comments = pref SGFComments, signMessages = pref SGFSignMessages, commands = Just cmds, - _json = PrefsJSON Nothing + _json = NoJSON Nothing } where pref :: SGroupFeature f -> Maybe (GroupFeaturePreference f) diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index ced51a5bbf..360c97edf0 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -53,6 +53,7 @@ badgeTests = do it "presentation headers encode and decode" testPresHeaderEncoding it "presentation headers with binary bytes round-trip through JSON" testPresHeaderJSON it "should reject a proof presented under another chat binding" testOtherChatBinding + it "should accept a profile proof only with the header of its chat" testProfileProofHeader describe "redemption codes" $ do it "a generated code reads back" testCodeRoundTrip it "reads a code as typed - any case, separators, ambiguous characters" testCodeNormalisation @@ -207,6 +208,8 @@ testPresHeaderEncoding = PHFileInv {chatBinding = aliceBinding, fileSize = 139737}, PHFileDescr {chatBinding = aliceBinding, fileSize = 139737, descrHash = "descr-hash", fileExpires = Nothing}, PHFileDescr {chatBinding = aliceBinding, fileSize = 139737, descrHash = "descr-hash", fileExpires = Just futureTime}, + PHRequest requestCode, + PHLink "link-key", PHUnknown 'Z' "payload" ] @@ -220,6 +223,8 @@ testPresHeaderJSON = [ PHChat binaryBytes, PHFileInv {chatBinding = binaryBytes, fileSize = 139737}, PHFileDescr {chatBinding = binaryBytes, fileSize = 139737, descrHash = binaryBytes, fileExpires = Just futureTime}, + PHRequest binaryBytes, + PHLink binaryBytes, PHUnknown 'Z' binaryBytes ] where @@ -233,12 +238,33 @@ testOtherChatBinding = do verifyBadge (keysFor pk) (BadgeProof idx (BBSPresHeader $ strEncode ph) p info) >>= (`shouldBe` Just True) verifyBadge (keysFor pk) (BadgeProof idx (BBSPresHeader $ strEncode otherPh) p info) >>= (`shouldBe` Just False) +testProfileProofHeader :: IO () +testProfileProofHeader = do + (pk, unbound) <- issueBadgeProof BTSupporter futureTime + (pk', bound) <- issueBadgeProofHeader BTSupporter futureTime (PHChat aliceBinding) + (pk'', requested) <- issueBadgeProofHeader BTSupporter futureTime (PHRequest requestCode) + acceptedProof Nothing unbound `shouldBe` True + acceptedProof (Just $ PHChat aliceBinding) unbound `shouldBe` True + acceptedProof (Just $ PHChat aliceBinding) bound `shouldBe` True + acceptedProof (Just $ PHChat bobBinding) bound `shouldBe` False + acceptedProof (Just $ PHRequest aliceBinding) bound `shouldBe` False + acceptedProof Nothing bound `shouldBe` False + acceptedProof (Just $ PHRequest requestCode) requested `shouldBe` True + acceptedProof (Just $ PHRequest "other-request-code") requested `shouldBe` False + acceptedProof (Just $ PHChat requestCode) requested `shouldBe` False + verifyBadge (keysFor pk) unbound >>= (`shouldBe` Just True) + verifyBadge (keysFor pk') bound >>= (`shouldBe` Just True) + verifyBadge (keysFor pk'') requested >>= (`shouldBe` Just True) + aliceBinding :: ByteString aliceBinding = "Galice-member-id" bobBinding :: ByteString bobBinding = "Gbob-member-id" +requestCode :: ByteString +requestCode = "request-code" + issueBadgeProof :: BadgeType -> UTCTime -> IO (BBSPublicKey, BadgeProof) issueBadgeProof bt expiry = issueBadgeProofHeader bt expiry (PHTest "test-nonce") diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e349a71f0a..bf53060a67 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -21,6 +21,7 @@ import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM (atomically) import Control.Monad (forM_, void, when) import Control.Monad.Except (runExceptT) +import Control.Monad.Reader (runReaderT) import Data.Bifunctor (second) import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B @@ -31,26 +32,28 @@ import Data.List (intercalate, isInfixOf, isSuffixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T import Simplex.Chat.Badges (FileSizeLimits (..)) -import Simplex.Chat.Controller (ChatController (ChatController, smpAgent), ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks) -import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) +import Simplex.Chat.Controller (ChatController (ChatController, smpAgent), ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks, withAgent) +import Simplex.Chat.Library.Internal (encodeConnInfo, getShortLinkConnReq, uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options -import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) -import Simplex.Messaging.Agent (sendMessages, vrValue) +import Simplex.Messaging.Agent (joinConnection, prepareConnectionToJoin, sendMessages, vrValue) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.DB (Binary (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (bbsKeyGen) -import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff) -import Simplex.Messaging.Protocol (MsgFlags (..)) +import Simplex.Messaging.Client (pattern NRMInteractive) +import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) +import Simplex.Messaging.Encoding.String (strDecode) +import Simplex.Messaging.Protocol (MsgFlags (..), SubscriptionMode (..)) import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Version @@ -306,6 +309,9 @@ chatGroupTests = do it "should report relay results when one relay deleted its address" testChannelCreateDeletedRelay it "should deliver support scope messages via relay" testChannelSupportScope it "should add relay to existing channel" testChannelAddRelay + it "should not add relay when the invitation owner key differs from link data" testChannelAddRelayOwnerKeyMismatch + it "should not add relay when the invitation group id differs from link data" testChannelAddRelayGroupIdMismatch + it "should ignore relay invitation without the owner's signature" testChannelRelayInvUnsigned it "should remove relay from channel" testChannelRemoveRelay it "should remove left relay from channel" testChannelRemoveLeftRelay describe "relay rejection" $ do @@ -11299,6 +11305,76 @@ testChannelAddRelay ps = [bob, cath] *<# "#team> hello" [dan, eve] *<# "#team> hello [>>]" +testChannelAddRelayOwnerKeyMismatch :: HasCallStack => TestParams -> IO () +testChannelAddRelayOwnerKeyMismatch ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + _ <- prepareChannel1Relay "team" alice bob + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + alice ##> ("/relays name=cath " <> cathSLink) + alice <## "ok" + (otherKey :: C.PublicKeyEd25519, otherPrivKey :: C.PrivateKeyEd25519) <- atomically . C.generateKeyPair =<< C.newRandom + withCCTransaction alice $ \db -> do + DB.execute db "UPDATE groups SET member_priv_key = ?" (Only otherPrivKey) + DB.execute db "UPDATE group_members SET member_pub_key = ? WHERE member_category = 'user'" (Only otherKey) + alice ##> "/_add relays #1 2" + alice <## "#team: group relays:" + alice <## " - relay id 1: active" + alice <## " - relay id 2: invited" + cath <## "exception: getLinkDataCreateRelayLink: owner key of invitation does not match link data" + queryRelayOwnStatus cath 1 `shouldReturn` Just "invited" + +testChannelAddRelayGroupIdMismatch :: HasCallStack => TestParams -> IO () +testChannelAddRelayGroupIdMismatch ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + _ <- prepareChannel1Relay "team" alice bob + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + alice ##> ("/relays name=cath " <> cathSLink) + alice <## "ok" + withCCTransaction alice $ \db -> + DB.execute db "UPDATE group_profiles SET public_group_id = ?" (Only (B64UrlByteString "other group id")) + alice ##> "/_add relays #1 2" + alice <## "#team: group relays:" + alice <## " - relay id 1: active" + alice <## " - relay id 2: invited" + cath <## "exception: getLinkDataCreateRelayLink: linkEntityId does not match publicGroupId of profile or invitation" + queryRelayOwnStatus cath 1 `shouldReturn` Just "invited" + +testChannelRelayInvUnsigned :: HasCallStack => TestParams -> IO () +testChannelRelayInvUnsigned ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + sendUnsignedRelayInv alice cathSLink + cath <##. "error: x.grp.relay.inv: invitation is not signed by the owner" + +sendUnsignedRelayInv :: TestCC -> String -> IO () +sendUnsignedRelayInv cc relayAddress = + withCCUser cc $ \user -> do + relayLink :: ShortLinkContact <- either fail pure $ strDecode (B.pack relayAddress) + (memberKey, _) <- atomically . C.generateKeyPair =<< C.newRandom + let inv = + GroupRelayInvitation + { fromMember = MemberIdRole (MemberId "owner") GROwner, + fromMemberProfile = aliceProfile, + relayMemberId = MemberId "relay", + groupLink = relayLink, + publicGroupId = Just $ B64UrlByteString "group id", + fromMemberKey = Just $ MemberKey memberKey + } + joinRelay = do + (_, _, cReq) <- getShortLinkConnReq NRMInteractive user relayLink + (connId, _) <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + dm <- encodeConnInfo $ XGrpRelayInv inv + withAgent $ \a -> joinConnection a NRMInteractive (aUserId user) connId True cReq dm PQSupportOff SMSubscribe + runExceptT joinRelay `runReaderT` chatController cc >>= either (fail . show) (\_ -> pure ()) + testChannelAddRelayWithRoster :: HasCallStack => TestParams -> IO () testChannelAddRelayWithRoster ps = withNewTestChat ps "alice" aliceProfile $ \alice -> diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 06b85fdb72..3779006d8d 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -3,6 +3,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PostfixOperators #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -16,6 +17,7 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad import Control.Monad.Except +import Control.Monad.Reader (runReaderT) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import qualified Data.Text as T @@ -23,24 +25,36 @@ import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds) import Data.Time.Format (defaultTimeLocale, formatTime) import qualified Data.Map.Strict as M -import Simplex.Chat.Badges (BadgeCredential, BadgeInfo (..), BadgePurchase (..), BadgeRequest (..), BadgeType (..), generateMasterKey, issueBadge, verifyPayment) -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks, storeCxt) +import Simplex.Chat.Badges (BadgeCredential, BadgeInfo (..), BadgeProof, BadgePurchase (..), BadgeRequest (..), BadgeStatus (..), BadgeType (..), LocalBadge (..), ProofPresHeader (..), badgeProof, generateMasterKey, issueBadge, verifyPayment) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (ChatController, smpAgent), ChatHooks (..), defaultChatHooks, storeCxt) +import Simplex.Chat.Library.Internal (encodeShortLinkData, groupMemberKey, redactedMemberProfile, sendDirectContactMessage, sendGroupMessage') import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) -import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..)) -import Simplex.Chat.Store.Shared (createContact) -import Simplex.Chat.Types (ConnStatus (..), Profile (..), GroupRejectionReason (..), profileFromName) +import Simplex.Chat.Protocol (ChatBinding (..), ChatMsgEvent (XInfo), ContactShortLinkData (ContactShortLinkData), LinkOwnerSig, MsgChatLink (..), MsgContent (..), encodeChatBinding) +import Simplex.Chat.Store.Groups (getGroupMembers) +import Simplex.Chat.Store.Shared (createContact, getGroupInfoKeys) +import Simplex.Chat.Types (AgentConnId (..), ConnStatus (..), GroupId, GroupInfo (..), GroupInfoKeys (..), GroupMember (..), Profile (..), GroupRejectionReason (..), fromLocalProfile, profileFromName, userProfileDirect, pattern VersionChat) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Chat.Types.UITheme +import Simplex.Messaging.Agent (setConnShortLink) import Simplex.Messaging.Agent.Env.SQLite +import Simplex.Messaging.Agent.Protocol (SConnectionMode (..), UserConnLinkData (..)) import Simplex.Messaging.Agent.RetryInterval +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Client (pattern NRMInteractive) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Util (decodeJSON, encodeJSON) +import Simplex.Messaging.Version (mkVersionRange) import System.Directory (copyFile, createDirectoryIfMissing) import Test.Hspec hiding (it) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +#endif chatProfileTests :: SpecWith TestParams chatProfileTests = do @@ -62,10 +76,30 @@ chatProfileTests = do it "present supporter badge to contacts" testUserBadgeBroadcast it "supporter badge sent to contact connecting after attach" testUserBadgeOnConnect it "supporter badge sent to member joining via group link" testUserBadgeGroupLink + it "supporter badge sent to member connecting in group" testUserBadgeGroupHandshake + it "supporter badge sent unsigned to member with version below 18 connecting in group" testUserBadgeGroupHandshakeOldMember + it "supporter badge sent to group members with profile update" testUserBadgeGroupUpdate it "expired supporter badge shows as expired" testUserBadgeExpired it "long-expired supporter badge is not presented" testUserBadgeExpiredOld it "incognito connection does not carry supporter badge" testUserBadgeIncognito it "supporter badge sent to contact connecting via address" testUserBadgeContactAddress + it "supporter badge in a request to an address" testUserBadgeAddressRequest + it "supporter badge in a request to an address with ratchet keys" testUserBadgeAddressRequestRatchet + it "supporter badge sent joining a one-time link" testUserBadgeInvitationJoin + it "supporter badge sent in a retried request to an address" testUserBadgeAddressConnectRetry + it "supporter badge sent in a retried join of a one-time link" testUserBadgeInvitationConnectRetry + it "supporter badge bound to another chat is ignored, stored badge is kept" testUserBadgeOtherBinding + it "supporter badge of member bound to another member is ignored, stored badge is kept" testUserBadgeMemberOtherBinding + it "supporter badge of member joining via group link, at request and after handshake" testUserBadgeGroupLinkJoiner + it "supporter badge of introduced member is taken from the introduction" testUserBadgeIntroduced + it "supporter badge of member invited via contact is forwarded in the introduction" testUserBadgeInvitedIntroduced + it "supporter badge of inviting host in the reply to the invited contact" testUserBadgeInvitingHost + it "supporter badge proof of member is deleted with a profile without badge" testUserBadgeMemberRemoved + it "supporter badge proof of member with contact is deleted with a profile without badge" testUserBadgeContactMemberRemoved + it "supporter badge in one-time link data" testUserBadgeInvitationLinkData + it "supporter badge in data of address getting its first short link" testUserBadgeAddressFirstShortLink + it "supporter badge in link data bound to another link is not shown" testUserBadgeLinkDataOtherLink + it "supporter badge in link data without a proof is not shown" testUserBadgeLinkDataNoProof describe "user contact link" $ do it "create and connect via contact link" testUserContactLink it "rotate address ratchet keys" testRotateAddressRatchetKeys @@ -395,6 +429,9 @@ testBadgeKeys = M.singleton 1 futureDate :: UTCTime futureDate = posixSecondsToUTCTime 4102444800 -- 2100-01-01 +pastDate :: UTCTime +pastDate = posixSecondsToUTCTime 1577836800 -- 2020-01-01 + -- issue a supporter badge credential with the given expiry (test issuer) issueTestBadge :: BBSSecretKey -> UTCTime -> IO BadgeCredential issueTestBadge sk = issueTestBadgeType sk BTSupporter @@ -494,6 +531,83 @@ testUserBadgeGroupLink ps = do bob <## "connection not verified, use /code command to see security code" bob <## currentChatVRangeInfo +testUserBadgeGroupHandshake :: HasCallStack => TestParams -> IO () +testUserBadgeGroupHandshake ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg3 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile cathProfile (test sk) ps + where + test sk alice bob cath = do + createGroup2 "team" alice bob + addTestBadge bob =<< issueTestBadge sk futureDate + connectUsers alice cath + addMember "team" alice cath GRAdmin + cath ##> "/j team" + concurrentlyN_ + [ alice <## "#team: cath joined the group", + do + cath <## "#team: you joined the group" + cath <## "#team: member bob (Bob) is connected", + do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected" + ] + cath ##> "/i #team bob" + cath <## "group ID: 1" + cath <##. "member ID: " + cath <## "supporter badge - active" + cath <## "expires 2100-01-01" + cath <## "receiving messages via: localhost" + cath <## "sending messages via: localhost" + cath <## "connection not verified, use /code command to see security code" + cath <## currentChatVRangeInfo + +testUserBadgeGroupHandshakeOldMember :: HasCallStack => TestParams -> IO () +testUserBadgeGroupHandshakeOldMember ps = do + Right (pk, sk) <- bbsKeyGen + let cfg = testCfg {badgePublicKeys = testBadgeKeys pk} + oldCfg = cfg {chatVRange = mkVersionRange (VersionChat 9) (VersionChat 17)} + withNewTestChatCfg ps oldCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> + withNewTestChatCfg ps oldCfg "cath" cathProfile $ \cath -> do + connectUsers alice bob + addTestBadge bob =<< issueTestBadge sk futureDate + createGroup2' "team" alice (bob, GRAdmin) False + memberProofHeader alice "team" "bob" `shouldReturn` Nothing + connectUsers alice cath + addMember "team" alice cath GRAdmin + cath ##> "/j team" + concurrentlyN_ + [ alice <## "#team: cath joined the group", + do + cath <## "#team: you joined the group" + cath <## "#team: member bob (Bob) is connected", + do + bob <## "#team: alice added cath (Catherine) to the group (connecting...)" + bob <## "#team: new member cath is connected" + ] + memberBadgeHeader cath "team" "bob" `shouldReturn` Nothing + +testUserBadgeGroupUpdate :: HasCallStack => TestParams -> IO () +testUserBadgeGroupUpdate ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg3 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile cathProfile (test sk) ps + where + test sk alice bob cath = do + createGroup3 "team" alice bob cath + addTestBadge bob =<< issueTestBadge sk futureDate + bob #> "#team hello" + alice <# "#team bob> hello" + cath <# "#team bob> hello" + cath ##> "/i #team bob" + cath <## "group ID: 1" + cath <##. "member ID: " + cath <## "supporter badge - active" + cath <## "expires 2100-01-01" + cath <## "receiving messages via: localhost" + cath <## "sending messages via: localhost" + cath <## "connection not verified, use /code command to see security code" + cath <## currentChatVRangeInfo + testUserBadgeContactAddress :: HasCallStack => TestParams -> IO () testUserBadgeContactAddress ps = do Right (pk, sk) <- bbsKeyGen @@ -570,7 +684,6 @@ testUserBadgeExpiredOld ps = do bob <## "connection not verified, use /code command to see security code" bob <## "quantum resistant end-to-end encryption" bob <## currentChatVRangeInfo - pastDate = posixSecondsToUTCTime 1577836800 -- 2020-01-01 testUserBadgeIncognito :: HasCallStack => TestParams -> IO () testUserBadgeIncognito ps = do @@ -600,6 +713,455 @@ testUserBadgeIncognito ps = do bob <## "quantum resistant end-to-end encryption" bob <## currentChatVRangeInfo +testUserBadgeAddressRequest :: HasCallStack => TestParams -> IO () +testUserBadgeAddressRequest ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge bob =<< issueTestBadge sk futureDate + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + requestBadgeHeader alice "bob" `shouldReturn` Just ("R", BSActive) + alice ##> "/ac bob" + alice <## "bob (Bob, * supporter): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob, * supporter): contact is connected") + fmap snd <$> contactBadgeHeader alice "bob" `shouldReturn` Just BSActive + +testUserBadgeAddressRequestRatchet :: HasCallStack => TestParams -> IO () +testUserBadgeAddressRequestRatchet ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge bob =<< issueTestBadge sk futureDate + alice ##> "/ad pq_ratchet=on" + (sLink, _) <- getContactLinks alice True + bob ##> ("/c " <> sLink) + alice <#? bob + requestBadgeHeader alice "bob" `shouldReturn` Just ("CD", BSActive) + alice ##> "/ac bob" + alice <## "bob (Bob, * supporter): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob, * supporter): contact is connected") + fmap snd <$> contactBadgeHeader alice "bob" `shouldReturn` Just BSActive + +testUserBadgeInvitationJoin :: HasCallStack => TestParams -> IO () +testUserBadgeInvitationJoin ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge bob =<< issueTestBadge sk futureDate + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob, * supporter): contact is connected") + contactBadgeHeader alice "bob" `shouldReturn` Just ("CD", BSActive) + +testUserBadgeAddressConnectRetry :: HasCallStack => TestParams -> IO () +testUserBadgeAddressConnectRetry ps = do + Right (pk, sk) <- bbsKeyGen + let cfg' = testCfg {badgePublicKeys = testBadgeKeys pk, agentConfig = testAgentCfg {persistErrorInterval = 0}} + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do + addTestBadge bob =<< issueTestBadge sk futureDate + withSmpServer' serverCfg' $ do + alice ##> "/ad" + (shortLink, fullLink) <- getContactLinks alice True + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "contact address: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) + bob <## "alice: contact is prepared" + alice <## "disconnected 1 connections on server localhost" + bob ##> "/_connect contact @2" + bob <##. "smp agent error: BROKER" + withSmpServer' serverCfg' $ do + alice <## "subscribed 1 connections on server localhost" + threadDelay 250000 + bob ##> "/_connect contact @2" + bob <## "alice: connection started" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + requestBadgeHeader alice "bob" `shouldReturn` Just ("R", BSActive) + alice ##> "/ac bob" + alice <## "bob (Bob, * supporter): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob, * supporter): contact is connected") + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" + where + serverCfg' = + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], + serverStoreCfg = persistentServerStoreCfg (tmpPath ps) + } + opts' = + testOpts + { coreOptions = + testCoreOpts + { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] + } + } + +testUserBadgeInvitationConnectRetry :: HasCallStack => TestParams -> IO () +testUserBadgeInvitationConnectRetry ps = do + Right (pk, sk) <- bbsKeyGen + let cfg' = testCfg {badgePublicKeys = testBadgeKeys pk, agentConfig = testAgentCfg {persistErrorInterval = 0}} + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do + addTestBadge bob =<< issueTestBadge sk futureDate + withSmpServer' serverCfg' $ do + alice ##> "/_connect 1" + (shortLink, fullLink) <- getInvitations alice + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "invitation link: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) + bob <## "alice: contact is prepared" + alice <## "disconnected 1 connections on server localhost" + bob ##> "/_connect contact @2" + bob <##. "smp agent error: BROKER" + withSmpServer' serverCfg' $ do + alice <## "subscribed 1 connections on server localhost" + threadDelay 250000 + bob ##> "/_connect contact @2" + bob <## "alice: connection started" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob, * supporter): contact is connected") + contactBadgeHeader alice "bob" `shouldReturn` Just ("CD", BSActive) + alice <## "disconnected 1 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" + where + serverCfg' = + (smpServerCfg ps) + { transports = [(smpTestPort2 ps, transport @TLS, False)], + serverStoreCfg = persistentServerStoreCfg (tmpPath ps) + } + opts' = + testOpts + { coreOptions = + testCoreOpts + { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] + } + } + +testUserBadgeOtherBinding :: HasCallStack => TestParams -> IO () +testUserBadgeOtherBinding ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test pk sk) ps + where + test pk sk alice bob = do + addTestBadge alice =<< issueTestBadge sk futureDate + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (bob <## "alice (Alice, * supporter): contact is connected") + (alice <## "bob (Bob): contact is connected") + contactBadgeHeader bob "alice" `shouldReturn` Just ("CD", BSActive) + legend <- issueTestBadgeType sk BTLegend futureDate + Right otherProof <- badgeProof pk legend (PHChat $ encodeChatBinding CBGroup "other chat") + withCCUser alice $ \user -> do + ct <- getTestCCContact alice 2 + let p = (userProfileDirect user Nothing (Just ct) True) {badge = Just otherProof} + runExceptT (sendDirectContactMessage user ct (XInfo p Nothing)) `runReaderT` chatController alice + >>= either (fail . show) (\_ -> pure ()) + alice #> "@bob hi" + bob <# "alice *> hi" + contactBadgeHeader bob "alice" `shouldReturn` Just ("CD", BSActive) + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <## "expires 2100-01-01" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testUserBadgeGroupLinkJoiner :: HasCallStack => TestParams -> IO () +testUserBadgeGroupLinkJoiner ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge bob =<< issueTestBadge sk futureDate + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + bob ##> "/_stop" + bob <## "chat stopped" + alice <## "bob (Bob): accepting request to join group #team..." + memberBadgeHeader alice "team" "bob" `shouldReturn` Just ("R", BSActive) + bob ##> "/_start" + bob <## "chat started" + bob <## "subscribed 1 connections on server localhost" + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + memberBadgeHeader alice "team" "bob" `shouldReturn` Just ("CG", BSActive) + +testUserBadgeIntroduced :: HasCallStack => TestParams -> IO () +testUserBadgeIntroduced ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg3 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile cathProfile (test sk) ps + where + test sk alice bob cath = do + addTestBadge bob =<< issueTestBadge sk futureDate + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + memberBadgeHeader alice "team" "bob" `shouldReturn` Just ("CG", BSActive) + bob ##> "/_stop" + bob <## "chat stopped" + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + alice <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: cath joined the group", + do + cath <## "#team: joining the group..." + cath <## "#team: you joined the group" + ] + alice #> "#team hello" + cath <# "#team alice> hello" + memberBadgeHeader cath "team" "bob" `shouldReturn` Just ("CG", BSActive) + +testUserBadgeInvitedIntroduced :: HasCallStack => TestParams -> IO () +testUserBadgeInvitedIntroduced ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg3 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile cathProfile (test sk) ps + where + test sk alice bob cath = do + createGroup2 "team" alice cath + connectUsers alice bob + addTestBadge bob =<< issueTestBadge sk futureDate + bob #> "@alice hi" + alice <# "bob *> hi" + cath ##> "/_stop" + cath <## "chat stopped" + addMember "team" alice bob GRAdmin + bob ##> "/j team" + concurrently_ + (alice <## "#team: bob joined the group") + (bob <## "#team: you joined the group") + memberBadgeHeader alice "team" "bob" `shouldReturn` Just ("CD", BSActive) + memberProofHeader alice "team" "bob" `shouldReturn` Just "CG" + bob ##> "/_stop" + bob <## "chat stopped" + cath ##> "/_start" + cath <## "chat started" + cath <## "subscribed 2 connections on server localhost" + cath <## "#team: alice added bob (Bob) to the group (connecting...)" + memberBadgeHeader cath "team" "bob" `shouldReturn` Just ("CG", BSActive) + +testUserBadgeInvitingHost :: HasCallStack => TestParams -> IO () +testUserBadgeInvitingHost ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + connectUsers alice bob + addTestBadge alice =<< issueTestBadge sk futureDate + alice #> "@bob hi" + bob <# "alice *> hi" + createGroup2' "team" alice (bob, GRAdmin) False + memberBadgeHeader bob "team" "alice" `shouldReturn` Just ("CD", BSActive) + memberProofHeader bob "team" "alice" `shouldReturn` Just "CG" + +testUserBadgeMemberRemoved :: HasCallStack => TestParams -> IO () +testUserBadgeMemberRemoved ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge bob =<< issueTestBadge sk futureDate + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + memberProofHeader alice "team" "bob" `shouldReturn` Just "CG" + addTestBadge bob =<< issueTestBadge sk pastDate + bob #> "#team hi" + alice <# "#team bob> hi" + memberProofHeader alice "team" "bob" `shouldReturn` Nothing + memberBadgeHeader alice "team" "bob" `shouldReturn` Nothing + +testUserBadgeContactMemberRemoved :: HasCallStack => TestParams -> IO () +testUserBadgeContactMemberRemoved ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + createGroup2 "team" alice bob + addTestBadge bob =<< issueTestBadge sk futureDate + bob #> "#team hello" + alice <# "#team bob> hello" + memberProofHeader alice "team" "bob" `shouldReturn` Just "CG" + addTestBadge bob =<< issueTestBadge sk pastDate + bob #> "#team hi" + alice <# "#team bob> hi" + memberProofHeader alice "team" "bob" `shouldReturn` Nothing + +testUserBadgeInvitationLinkData :: HasCallStack => TestParams -> IO () +testUserBadgeInvitationLinkData ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk futureDate + alice ##> "/_connect 1" + (shortLink, fullLink) <- getInvitations alice + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "invitation link: ok to connect" + sLinkData <- getTermLine bob + sLinkData `shouldContain` "\"localBadge\":{\"badge\":{\"badgeType\":\"supporter\"" + sLinkData `shouldContain` "\"status\":\"active\"" + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> sLinkData) + bob <## "alice: contact is prepared" + contactBadgeHeader bob "alice" `shouldReturn` Just ("L", BSActive) + +testUserBadgeAddressFirstShortLink :: HasCallStack => TestParams -> IO () +testUserBadgeAddressFirstShortLink ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test sk) ps + where + test sk alice bob = do + addTestBadge alice =<< issueTestBadge sk futureDate + alice ##> "/ad" + _ <- getContactLinks alice True + withCCTransaction alice $ \db -> DB.execute_ db "UPDATE user_contact_links SET short_link_contact = NULL" + alice ##> "/_short_link_address 1" + (shortLink, _) <- getContactLinks alice False + alice <## "auto_accept off" + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "contact address: ok to connect" + sLinkData <- getTermLine bob + sLinkData `shouldContain` "\"localBadge\":{\"badge\":{\"badgeType\":\"supporter\"" + sLinkData `shouldContain` "\"status\":\"active\"" + +testUserBadgeMemberOtherBinding :: HasCallStack => TestParams -> IO () +testUserBadgeMemberOtherBinding ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg3 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile cathProfile (test pk sk) ps + where + test pk sk alice bob cath = do + createGroup3 "team" alice bob cath + addTestBadge bob =<< issueTestBadge sk futureDate + bob #> "#team hello" + alice <# "#team bob> hello" + cath <# "#team bob> hello" + memberBadgeHeader cath "team" "bob" `shouldReturn` Just ("CG", BSActive) + legend <- issueTestBadgeType sk BTLegend futureDate + Right otherProof <- badgeProof pk legend (PHChat $ encodeChatBinding CBGroup "other member") + sendTestMemberProof bob 1 otherProof + bob #> "#team hi" + alice <# "#team bob> hi" + cath <# "#team bob> hi" + memberProofHeader cath "team" "bob" `shouldReturn` Just "CG" + cath ##> "/i #team bob" + cath <## "group ID: 1" + cath <##. "member ID: " + cath <## "supporter badge - active" + cath <## "expires 2100-01-01" + cath <## "receiving messages via: localhost" + cath <## "sending messages via: localhost" + cath <## "connection not verified, use /code command to see security code" + cath <## currentChatVRangeInfo + +testUserBadgeLinkDataOtherLink :: HasCallStack => TestParams -> IO () +testUserBadgeLinkDataOtherLink ps = do + Right (pk, sk) <- bbsKeyGen + testChatCfg2 (testCfg {badgePublicKeys = testBadgeKeys pk}) aliceProfile bobProfile (test pk sk) ps + where + test pk sk alice bob = do + cred <- issueTestBadge sk futureDate + addTestBadge alice cred + alice ##> "/_connect 1" + (shortLink, _) <- getInvitations alice + Right otherProof <- badgeProof pk cred (PHLink "other link key") + setTestInvLinkData alice $ ContactShortLinkData aliceProfile {badge = Just otherProof} Nothing False Nothing + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "invitation link: ok to connect" + sLinkData <- getTermLine bob + sLinkData `shouldContain` "\"proof\":" + sLinkData `shouldNotContain` "localBadge" + +testUserBadgeLinkDataNoProof :: HasCallStack => TestParams -> IO () +testUserBadgeLinkDataNoProof = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/_connect 1" + (shortLink, _) <- getInvitations alice + let shownBadge = ShownBadge BadgeInfo {badgeType = BTSupporter, badgeExpiry = futureDate, badgeExtra = ""} BSActive + setTestInvLinkData alice $ ContactShortLinkData aliceProfile Nothing False (Just shownBadge) + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "invitation link: ok to connect" + sLinkData <- getTermLine bob + sLinkData `shouldNotContain` "localBadge" + +sendTestMemberProof :: TestCC -> GroupId -> BadgeProof -> IO () +sendTestMemberProof cc groupId proof = + withCCUser cc $ \user -> do + let cxt = storeCxt $ chatController cc + (g@(GIK gInfo@GroupInfo {membership = m@GroupMember {memberProfile}} gks), members) <- withCCTransaction cc $ \db -> do + g@(GIK gInfo _) <- runExceptT (getGroupInfoKeys db cxt user groupId) >>= either (fail . show) pure + members <- getGroupMembers db cxt user gInfo + pure (g, members) + let p = (redactedMemberProfile gInfo m $ fromLocalProfile memberProfile) {badge = Just proof} + runExceptT (sendGroupMessage' user g members (XInfo p (Just $ groupMemberKey gks))) `runReaderT` chatController cc + >>= either (fail . show) (\_ -> pure ()) + +setTestInvLinkData :: TestCC -> ContactShortLinkData -> IO () +setTestInvLinkData cc linkData = do + [Only (AgentConnId connId)] <- withCCTransaction cc $ \db -> DB.query_ db "SELECT agent_conn_id FROM connections" + let ChatController {smpAgent} = chatController cc + runExceptT (setConnShortLink smpAgent NRMInteractive connId SCMInvitation (UserInvLinkData $ encodeShortLinkData linkData) Nothing False Nothing) + >>= either (fail . show) (\_ -> pure ()) + testUpdateProfileImage :: HasCallStack => TestParams -> IO () testUpdateProfileImage = testChat2 aliceProfile bobProfile $ @@ -3743,8 +4305,8 @@ testShortLinkDeletedAddress = testChat2 aliceProfile bobProfile test testShortLinkAddressConnectRetry :: HasCallStack => TestParams -> IO () testShortLinkAddressConnectRetry ps = - withNewTestChatOpts ps opts' "alice" aliceProfile $ \alice -> - withNewTestChatOpts ps opts' "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do shortLink <- withSmpServer' serverCfg' $ do alice ##> "/ad" (shortLink, fullLink) <- getContactLinks alice True @@ -3788,6 +4350,7 @@ testShortLinkAddressConnectRetry ps = { transports = [(smpTestPort2 ps, transport @TLS, False)], serverStoreCfg = persistentServerStoreCfg tmp } + cfg' = testCfg {agentConfig = testAgentCfg {persistErrorInterval = 0}} opts' = testOpts { coreOptions = @@ -3798,8 +4361,8 @@ testShortLinkAddressConnectRetry ps = testShortLinkAddressConnectRetryIncognito :: HasCallStack => TestParams -> IO () testShortLinkAddressConnectRetryIncognito ps = - withNewTestChatOpts ps opts' "alice" aliceProfile $ \alice -> - withNewTestChatOpts ps opts' "bob" bobProfile $ \bob -> do + withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice -> + withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do shortLink <- withSmpServer' serverCfg' $ do alice ##> "/ad" (shortLink, fullLink) <- getContactLinks alice True @@ -3851,6 +4414,7 @@ testShortLinkAddressConnectRetryIncognito ps = { transports = [(smpTestPort2 ps, transport @TLS, False)], serverStoreCfg = persistentServerStoreCfg tmp } + cfg' = testCfg {agentConfig = testAgentCfg {persistErrorInterval = 0}} opts' = testOpts { coreOptions = diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index cd975a32c3..c1f58aa525 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -23,12 +23,14 @@ import Data.List (isPrefixOf, isSuffixOf) import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T +import Simplex.Chat.Badges (BadgeProof (..), BadgeStatus, LocalBadge (..), ProofPresHeader (..)) import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), storeCxt) import Simplex.Chat.Library.Commands (maxProfileImageSize) import Simplex.Chat.Markdown (viewName) import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText) import Simplex.Chat.Protocol -import Simplex.Chat.Store.Direct (getContact) +import Simplex.Chat.Store.Direct (getContact, getContactIdByName, getContactRequest, getContactRequestIdByName) +import Simplex.Chat.Store.Groups (getGroupIdByName, getGroupMember, getGroupMemberIdByName) import Simplex.Chat.Store.NoteFolders (createNoteFolder) import Simplex.Chat.Store.Profiles (getUserContactProfiles) import Simplex.Chat.Types @@ -39,6 +41,7 @@ import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow, withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSPresHeader (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version @@ -726,6 +729,50 @@ getTestCCContact cc contactId = do withCCUser cc $ \user -> runExceptT (getContact db (storeCxt chatController) user contactId) >>= either (fail . show) pure +contactBadgeHeader :: TestCC -> ContactName -> IO (Maybe (String, BadgeStatus)) +contactBadgeHeader cc cName = + withCCTransaction cc $ \db -> + withCCUser cc $ \user -> + runExceptT (getContact db (storeCxt $ chatController cc) user =<< getContactIdByName db user cName) + >>= either (fail . show) (pure . storedBadgeHeader . profile') + +requestBadgeHeader :: TestCC -> ContactName -> IO (Maybe (String, BadgeStatus)) +requestBadgeHeader cc cName = + withCCTransaction cc $ \db -> + withCCUser cc $ \user@User {userId} -> + runExceptT (getContactRequest db user =<< getContactRequestIdByName db userId cName) + >>= either (fail . show) (\UserContactRequest {profile} -> pure $ storedBadgeHeader profile) + +memberBadgeHeader :: TestCC -> GroupName -> ContactName -> IO (Maybe (String, BadgeStatus)) +memberBadgeHeader cc gName mName = (\GroupMember {memberProfile} -> storedBadgeHeader memberProfile) <$> getTestMember cc gName mName + +memberProofHeader :: TestCC -> GroupName -> ContactName -> IO (Maybe String) +memberProofHeader cc gName mName = (\GroupMember {memberBadgeProof} -> proofHeaderTag <$> unNoJSON memberBadgeProof) <$> getTestMember cc gName mName + +getTestMember :: TestCC -> GroupName -> ContactName -> IO GroupMember +getTestMember cc gName mName = + withCCTransaction cc $ \db -> + withCCUser cc $ \user -> + runExceptT + ( do + gId <- getGroupIdByName db user gName + getGroupMember db (storeCxt $ chatController cc) user gId =<< getGroupMemberIdByName db user gId mName + ) + >>= either (fail . show) pure + +storedBadgeHeader :: LocalProfile -> Maybe (String, BadgeStatus) +storedBadgeHeader LocalProfile {localBadge} = case localBadge of + Just (PeerBadge b st) -> Just (proofHeaderTag b, st) + _ -> Nothing + +proofHeaderTag :: BadgeProof -> String +proofHeaderTag (BadgeProof _ (BBSPresHeader ph) _ _) = case strDecode ph of + Right (PHChat b) -> 'C' : take 1 (B.unpack b) + Right (PHRequest _) -> "R" + Right (PHLink _) -> "L" + Right (PHTest _) -> "T" + _ -> "?" + lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do cc ##> "/last_item_id" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 2d9842e9ee..35f1ea5885 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -185,10 +185,10 @@ s #==# msg = do s ==# msg testChatPreferences :: Maybe Preferences -testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, files = Nothing, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}, sessions = Nothing, commands = Nothing, _json = PrefsJSON Nothing} +testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, files = Nothing, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}, sessions = Nothing, commands = Nothing, _json = NoJSON Nothing} testGroupPreferences :: Maybe GroupPreferences -testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, signMessages = Nothing, commands = Nothing, _json = PrefsJSON Nothing} +testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, signMessages = Nothing, commands = Nothing, _json = NoJSON Nothing} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, description = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} @@ -240,7 +240,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"9-21\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"9-22\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello"))) it "x.msg.new quote" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -352,18 +352,21 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberKey = Nothing, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} it "x.grp.acpt without incognito profile" $ "{\"v\":\"9\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" - #==# XGrpAcpt (MemberId "\1\2\3\4") Nothing + #==# XGrpAcpt (MemberId "\1\2\3\4") Nothing Nothing + it "x.grp.acpt with profile" $ + "{\"v\":\"9\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" + #==# XGrpAcpt (MemberId "\1\2\3\4") Nothing (Just testProfile) it "x.grp.mem.new" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-22\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-22\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -372,7 +375,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq} it "x.grp.mem.fwd with member chat version range" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-22\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq} it "x.grp.mem.info" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" From d72f68e6dcd7e014a539108b10e66f76b3c07853 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:08:35 +0000 Subject: [PATCH 03/31] android, desktop: free strings returned by the core (#7595) The core allocates every chat_* response with malloc and passes ownership to the caller, as iOS does in API.swift. Both JNI bindings copied the bytes into a Java string and dropped the pointer, so every command, event and file read leaked its whole response into the C allocator - invisible to both the JVM and the Haskell heap profiler. Desktop frees in decode_to_utf8_string, the funnel every entry point already goes through. Android has no such funnel because NewStringUTF is called inline, so decode_and_free is added and the call sites routed through it. chatReadFile frees directly on both. Co-authored-by: Evgeny Poberezkin --- .../src/commonMain/cpp/android/simplex-api.c | 41 ++++--- .../src/commonMain/cpp/desktop/simplex-api.c | 2 + plans/2026-09-26-free-core-strings-in-jni.md | 103 ++++++++++++++++++ 3 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 plans/2026-09-26-free-core-strings-in-jni.md diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index c0497daacb..f66fd82473 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -4,6 +4,9 @@ //#include //#include +// from libc (stdlib.h is not included because of the reallocarray stub below) +void free(void *ptr); + // from the RTS void hs_init_with_rtsopts(int * argc, char **argv[]); @@ -49,6 +52,13 @@ Java_chat_simplex_common_platform_CoreKt_initHS(__unused JNIEnv *env, __unused j // from simplex-chat typedef long* chat_ctrl; +// Strings returned by chat_* functions are allocated with malloc and owned by the caller. +static jstring decode_and_free(JNIEnv *env, char *string) { + jstring res = (*env)->NewStringUTF(env, string); + free(string); + return res; +} + /* When you start using any new function from Haskell libraries, you have to add the function name to the file libsimplex.dll.def in the root directory. @@ -80,7 +90,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE); const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE); jlong _ctrl = (jlong) 0; - jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); + jstring res = decode_and_free(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey); (*env)->ReleaseStringUTFChars(env, confirm, _confirm); @@ -99,7 +109,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatCloseStore(JNIEnv *env, __unused jclass clazz, jlong controller) { - jstring res = (*env)->NewStringUTF(env, chat_close_store((void*)controller)); + jstring res = decode_and_free(env, chat_close_store((void*)controller)); return res; } @@ -109,7 +119,7 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmdRetry(JNIEnv *env, __unused //jint length = (jint) (*env)->GetStringUTFLength(env, msg); //for (int i = 0; i < length; ++i) // __android_log_print(ANDROID_LOG_ERROR, "simplex", "%d: %02x\n", i, _msg[i]); - jstring res = (*env)->NewStringUTF(env, chat_send_cmd_retry((void*)controller, _msg, retryNum)); + jstring res = decode_and_free(env, chat_send_cmd_retry((void*)controller, _msg, retryNum)); (*env)->ReleaseStringUTFChars(env, msg, _msg); return res; } @@ -117,25 +127,25 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmdRetry(JNIEnv *env, __unused JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatSendRemoteCmdRetry(JNIEnv *env, __unused jclass clazz, jlong controller, jint rhId, jstring msg, jint retryNum) { const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_send_remote_cmd_retry((void*)controller, rhId, _msg, retryNum)); + jstring res = decode_and_free(env, chat_send_remote_cmd_retry((void*)controller, rhId, _msg, retryNum)); (*env)->ReleaseStringUTFChars(env, msg, _msg); return res; } JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) { - return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller)); + return decode_and_free(env, chat_recv_msg((void*)controller)); } JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) { - return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait)); + return decode_and_free(env, chat_recv_msg_wait((void*)controller, wait)); } JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str)); + jstring res = decode_and_free(env, chat_parse_markdown(_str)); (*env)->ReleaseStringUTFChars(env, str, _str); return res; } @@ -143,7 +153,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, __unused JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str)); + jstring res = decode_and_free(env, chat_parse_server(_str)); (*env)->ReleaseStringUTFChars(env, str, _str); return res; } @@ -151,7 +161,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused j JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, __unused jclass clazz, jstring str, jint safe) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_uri(_str, safe)); + jstring res = decode_and_free(env, chat_parse_uri(_str, safe)); (*env)->ReleaseStringUTFChars(env, str, _str); return res; } @@ -160,7 +170,7 @@ JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) { const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE); const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt)); + jstring res = decode_and_free(env, chat_password_hash(_pwd, _salt)); (*env)->ReleaseStringUTFChars(env, pwd, _pwd); (*env)->ReleaseStringUTFChars(env, salt, _salt); return res; @@ -169,7 +179,7 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) { const char *_name = (*env)->GetStringUTFChars(env, name, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_valid_name(_name)); + jstring res = decode_and_free(env, chat_valid_name(_name)); (*env)->ReleaseStringUTFChars(env, name, _name); return res; } @@ -177,7 +187,7 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseBadgeCode(JNIEnv *env, jclass clazz, jstring code) { const char *_code = (*env)->GetStringUTFChars(env, code, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_badge_code(_code)); + jstring res = decode_and_free(env, chat_parse_badge_code(_code)); (*env)->ReleaseStringUTFChars(env, code, _code); return res; } @@ -195,7 +205,7 @@ Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); - jstring res = (*env)->NewStringUTF(env, chat_write_file((void*)controller, _path, buff, capacity)); + jstring res = decode_and_free(env, chat_write_file((void*)controller, _path, buff, capacity)); (*env)->ReleaseStringUTFChars(env, path, _path); return res; } @@ -229,6 +239,7 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, arr = (*env)->NewByteArray(env, len); (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1); } + free(res); jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"), @@ -243,7 +254,7 @@ JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jlong controller, jstring from_path, jstring to_path) { const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE); const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_encrypt_file((void*)controller, _from_path, _to_path)); + jstring res = decode_and_free(env, chat_encrypt_file((void*)controller, _from_path, _to_path)); (*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, to_path, _to_path); return res; @@ -255,7 +266,7 @@ Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass cla const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE); const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE); const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path)); + jstring res = decode_and_free(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path)); (*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, key, _key); (*env)->ReleaseStringUTFChars(env, nonce, _nonce); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 6bb6ffbe97..fddeb16204 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -64,6 +64,7 @@ jstring decode_to_utf8_string(JNIEnv *env, char *string) { (*env)->DeleteLocalRef(env, bb); (*env)->DeleteLocalRef(env, charset); (*env)->DeleteLocalRef(env, cb); + free(string); return res; } @@ -239,6 +240,7 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, arr = (*env)->NewByteArray(env, len); (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1); } + free(res); jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"), diff --git a/plans/2026-09-26-free-core-strings-in-jni.md b/plans/2026-09-26-free-core-strings-in-jni.md new file mode 100644 index 0000000000..effd2374a7 --- /dev/null +++ b/plans/2026-09-26-free-core-strings-in-jni.md @@ -0,0 +1,103 @@ +# Free strings returned by the core in the JNI bindings + +## Problem + +On desktop and Android, resident memory grows for as long as the app runs and is +only released by restarting it. On a profile with ~200k connections it reaches +several GB within a day; the growth continues while the app is idle. + +None of it is visible to either heap tool. The JVM heap stays small, and the +Haskell heap accounts for its own region only — the memory is in the C allocator, +which neither profiler walks. + +## Cause + +Every `chat_*` entry point returns a string that the core allocates with `malloc` +and hands to the caller. `Simplex.Chat.Mobile.Shared` is explicit about it: + +```haskell +newCStringFromLazyBS :: LB.ByteString -> IO CString +newCStringFromLazyBS s = do + ... + buf <- mallocBytes (len + 1) +``` + +The caller owns that buffer. iOS honours the contract — `SimpleXChat/API.swift` +calls `free(c)` after copying each response into a Swift string. The two JNI +bindings never do. + +Both of them copy the bytes into a Java string and then drop the pointer: + +- desktop `decode_to_utf8_string` wraps the buffer in a `DirectByteBuffer`, + decodes it through `Charset.decode`, returns the resulting `String`, and + deletes only the JNI local references; +- Android calls `NewStringUTF(env, chat_*())` inline at every entry point, so the + pointer is never even bound to a variable. + +`chatReadFile` leaks the same way on both platforms, after `SetByteArrayRegion` +has already copied the payload. + +So every command, every received event, and every file read leaks its whole +response. Nothing bounds it: the buffers are unreachable from both runtimes, the +Java string is a copy, and no code path retains the original. + +### Measurements + +A direct-FFI harness calling `chat_send_cmd_retry` in a loop, outside the JVM, +separates the two cases cleanly: + +| harness | behaviour | +|---|---| +| response pointer dropped (current bindings) | +5 KB per call, linear, no plateau | +| response pointer freed | rises to 138 MB and stays flat | + +Per interface event the mean response is ~3.9 KB, so the rate follows activity +rather than connection count. + +On a desktop client running the affected profile, `/proc//smaps` attributes +roughly 1.0 GB to glibc's non-main arenas — sixteen mappings of 63-64 MB, which is +`HEAP_MAX_SIZE` on x86-64, aligned to 64 MB boundaries. Those pages are counted as +touched, not merely reserved. Over the ~10 hours of uptime that is on the order of +100 MB/hour, against a Haskell heap of 1.70 GB and a JVM heap of 328 MB in the +same process. + +## Fix + +Free each buffer once its contents have been copied. + +Desktop already has a single funnel, `decode_to_utf8_string`, which every entry +point goes through, so one `free` covers all of them. Android has no such funnel +because `NewStringUTF` is called inline, so the fix adds `decode_and_free` and +routes the sixteen call sites through it. `chatReadFile` frees its buffer directly +on both platforms. + +Android cannot include `stdlib.h` — the file defines a `reallocarray` stub that +conflicts with it — so `free` is declared on its own. + +The change is confined to the two binding files and does not alter what either +function returns. + +### Why this is safe + +In every case the copy is complete before the buffer is freed: + +- `Charset.decode` produces a `CharBuffer` whose backing array is freshly + allocated, and `toString` copies again into the `String`; +- `NewStringUTF` copies into a new Java string; +- `SetByteArrayRegion` copies into the `byte[]` before either `free` runs. + +Nothing retains the original pointer. On desktop the `DirectByteBuffer` is a view +over it, but it is a JNI local reference deleted in the same function and never +escapes to Java. + +Note that the *input* direction was already correct by accident: the bindings pass +the `malloc`ed buffers from `encode_to_utf8_chars` to `ReleaseStringUTFChars`, +which is not the matching deallocator but reaches `free` through HotSpot's +`FreeHeap`. That mismatch is left alone here. + +## Verification + +- Desktop AppImage with the fix, on a fresh profile: flat at 806-808 MB RSS + through 35 minutes of traffic followed by 10 minutes idle. +- Both `free` calls confirmed in the shipped binary by disassembling + `libapp-lib.so` out of the AppImage. From afcaf22100675761ca5a732a61c15eca09f227a3 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:55:08 +0000 Subject: [PATCH 04/31] android, desktop: storage breakdown in developer options (#7603) * android, desktop: storage breakdown in developer options Port of the iOS Storage screen (#5529): lists each top-level entry of the app's data, config and temp folders with its recursive size, so it is possible to see what takes space. * android, desktop: simplify storage view Use DefaultProgressView, drop the bold total and redundant absoluteFile, and do not follow top-level symlinks (matches iOS; avoids counting the Android lib link). * android, desktop: storage view: do not crash on names the JVM cannot convert to Path Under a non-UTF-8 locale on Linux, File.toPath() throws InvalidPathException for a non-ASCII top-level name (e.g. a received file decrypted into tmpDir); log it and show 0 bytes. Also correct plan wording. * android, desktop: storage view: fold path conversion into treeSize, use produceState Also correct plan wording (du per-directory overhead, unlistable roots, Windows junctions). * android, desktop: storage view: open as card screen like other settings screens * android, desktop: storage view: list top-level entries as Path to keep their exact names File.listFiles + toPath loses names that are not valid in the JVM file-name encoding (non-ASCII under a C locale, invalid UTF-8), so those entries showed 0 bytes. Files.newDirectoryStream keeps the raw bytes. * android, desktop: storage view: normalize root paths before dropping nested roots A root spelled with .. (e.g. XDG_DATA_HOME=/x/data/../tmp) compared as nested in another root and its size was shown nowhere. * android, desktop: document storage view in spec and product docs * android, desktop: storage view: single catch when listing a root * android, desktop: do not log file paths in storage view The walk's IOException messages contain the full path, which in tmpDir can be a received file's original name. --------- Co-authored-by: Evgeny Poberezkin --- .../views/usersettings/DeveloperView.kt | 1 + .../common/views/usersettings/StorageView.kt | 100 ++++++++++++++++++ .../moko-resources/base/strings.xml | 2 + apps/multiplatform/product/README.md | 1 + apps/multiplatform/product/views/settings.md | 1 + apps/multiplatform/spec/impact.md | 1 + ...2026-09-27-android-desktop-storage-view.md | 53 ++++++++++ 7 files changed, 159 insertions(+) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt create mode 100644 plans/2026-09-27-android-desktop-storage-view.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index cfdf003bc6..fce6c69033 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -60,6 +60,7 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> } SettingsPreferenceItem(painterResource(MR.images.ic_report), stringResource(MR.strings.show_internal_errors), appPreferences.showInternalErrors) SettingsPreferenceItem(painterResource(MR.images.ic_avg_pace), stringResource(MR.strings.show_slow_api_calls), appPreferences.showSlowApiCalls) + SettingsActionItem(painterResource(MR.images.ic_folder_open), stringResource(MR.strings.storage), { ModalManager.start.showModal(cardScreen = true) { StorageView() } }) } } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt new file mode 100644 index 0000000000..a3e0c3206b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt @@ -0,0 +1,100 @@ +package chat.simplex.common.views.usersettings + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemViewSpaceBetween +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.nio.file.* +import java.nio.file.attribute.BasicFileAttributes + +private class EntryUsage(val name: String, val bytes: Long) + +private class RootUsage(val dir: File, val entries: List) { + val bytes: Long = entries.sumOf { it.bytes } +} + +@Composable +fun StorageView() { + val usage by produceState?>(initialValue = null) { + value = withContext(Dispatchers.IO) { storageRoots().map(::rootUsage) } + } + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.storage)) + val roots = usage + if (roots == null) { + DefaultProgressView(null) + } else { + roots.forEachIndexed { i, root -> + if (i > 0) SectionDividerSpaced() + SectionView(root.dir.path) { + UsageRow(stringResource(MR.strings.storage_total), root.bytes) + root.entries.forEach { UsageRow(it.name, it.bytes) } + } + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun UsageRow(name: String, bytes: Long) { + SectionItemViewSpaceBetween { + Text(name, Modifier.weight(1f)) + Text(formatBytes(bytes), Modifier.padding(start = DEFAULT_PADDING), color = MaterialTheme.colors.secondary) + } +} + +private fun storageRoots(): List { + val dirs = listOf(dataDir, preferencesDir, tmpDir).map { it.normalize() }.distinct() + return dirs.filter { dir -> dirs.none { it != dir && dir.startsWith(it) } } +} + +private fun rootUsage(root: File): RootUsage = + RootUsage( + root, + rootEntries(root) + .map { EntryUsage(it.fileName.toString(), treeSize(it)) } + .sortedByDescending { it.bytes } + ) + +private fun rootEntries(root: File): List = + try { + Files.newDirectoryStream(root.toPath()).use { it.toList() } + } catch (e: Exception) { + Log.e(TAG, "StorageView rootEntries: ${e.javaClass.simpleName}") + emptyList() + } + +private fun treeSize(path: Path): Long { + var bytes = 0L + Files.walkFileTree(path, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + bytes += attrs.size() + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + Log.e(TAG, "StorageView visitFileFailed: ${exc.javaClass.simpleName}") + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) Log.e(TAG, "StorageView postVisitDirectory: ${exc.javaClass.simpleName}") + return FileVisitResult.CONTINUE + } + }) + return bytes +} diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 71831e1c89..3a1720d883 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -1160,6 +1160,8 @@ Deprecated options Show internal errors Show slow API calls + Storage + Total Shutdown? Notifications will stop working until you re-launch the app Error saving settings diff --git a/apps/multiplatform/product/README.md b/apps/multiplatform/product/README.md index 173def8ae7..5dc606a50e 100644 --- a/apps/multiplatform/product/README.md +++ b/apps/multiplatform/product/README.md @@ -277,6 +277,7 @@ SettingsView -> CallSettings .............. (call preferences) -> VersionInfoView ........... (about/version) -> DeveloperView ............. (developer options) + -> StorageView ............. (storage breakdown) -> HelpView .................. (help & support) UserPicker diff --git a/apps/multiplatform/product/views/settings.md b/apps/multiplatform/product/views/settings.md index e668bf2d04..563fa09d5b 100644 --- a/apps/multiplatform/product/views/settings.md +++ b/apps/multiplatform/product/views/settings.md @@ -139,6 +139,7 @@ Platform-specific section (expect/actual composable): |---|---| | App updates (Desktop) | App update checker and installer | | Developer tools | Toggle developer mode | +| Storage (developer options) | Opens `StorageView`: size of each top-level entry in the app's data, config and temp folders | | Chat console | Opens `ChatConsoleView` terminal | | Terminal always visible (Desktop) | Keep terminal window open | | Install terminal app | Link to CLI app on GitHub | diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index ec302e30b5..7e0fb181d8 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -220,6 +220,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `views/usersettings/SetDeliveryReceiptsView.kt` | PC2 | Low | Delivery receipts toggle | | `views/usersettings/RTCServers.kt` | PC17, PC25 | Medium | WebRTC ICE server configuration | | `views/usersettings/DeveloperView.kt` | — | Low | Developer/debug settings | +| `views/usersettings/StorageView.kt` | — | Low | Storage breakdown (developer options) | | `views/usersettings/HelpView.kt` | — | Low | Help and support links | | `views/usersettings/MarkdownHelpView.kt` | PC4 | Low | Markdown formatting guide | | `views/usersettings/VersionInfoView.kt` | — | Low | Version display | diff --git a/plans/2026-09-27-android-desktop-storage-view.md b/plans/2026-09-27-android-desktop-storage-view.md new file mode 100644 index 0000000000..fbeb1be6f8 --- /dev/null +++ b/plans/2026-09-27-android-desktop-storage-view.md @@ -0,0 +1,53 @@ +# Android, desktop: storage breakdown in developer options + +## Problem + +When the Android or desktop app takes a lot of space, there is no way to see from inside the app what is using it. The Database screen shows only the count and size of received files (`directoryFileCountAndSize(appFilesDir.absolutePath)`, `Utils.kt`), and that helper is not recursive: it adds up `File.length()` of the direct children only. + +The databases, their `-wal`/`.bak` copies, wallpapers, core temp files, migration leftovers, remote-host data and preferences are all invisible. On one desktop profile, for example, the `.bak` copies left by database migrations were 5.6 MB of the 13 MB of database files. + +## Cause + +iOS has had this screen since #5529 (`apps/ios/Shared/Views/UserSettings/StorageView.swift`, under Developer options), but it was never ported to Kotlin. + +## Fix + +A Storage item in Developer options (shown only when "Show developer options" is on) opens `StorageView`. The screen lists each top-level entry of the app's storage folders with its recursive size, largest first, and a total per folder. + +Folders measured: `dataDir`, `preferencesDir` and `tmpDir`. Any folder that is the same as, or inside, another one is dropped, so nothing is counted twice: + +| Platform | Folders shown | +|---|---| +| Android | `dataDir` only. `shared_prefs` and `app_temp` are inside it, and so are the databases, `files/`, `cache/` (exports) and `app_temp/remote_hosts`. | +| Linux, macOS | `$XDG_DATA_HOME/simplex`, `$XDG_CONFIG_HOME/simplex`, `java.io.tmpdir/simplex` | +| Windows | `%AppData%\SimpleX` (config and data are the same folder), `java.io.tmpdir\simplex` (usually `%TEMP%`) | + +Like iOS, it shows whatever entries actually exist rather than named categories. Named categories would need to be kept in sync with the path helpers and would hide anything unexpected, and unexpected entries (such as `.bak` files) are what this screen is for. + +It improves on iOS in four ways: + +- It measures on `Dispatchers.IO`; iOS measures on the main thread in `.onAppear`. +- It sorts rows by size; iOS iterates a dictionary, so the order is random. +- It shows a total per folder. +- A file or folder inside the walk that cannot be read is logged and skipped, and a storage folder that cannot be listed shows as empty. On iOS, a failure to read one entry's attributes ends the whole walk. + +The walk uses `Files.walkFileTree` (API 26 = minSdk) without `FOLLOW_LINKS`, as iOS's `FileManager.enumerator` does not follow links either: a symlink is counted as the link itself, so it cannot loop, escape to `/` (Windows directory junctions are not treated as links by the JDK, but the app never creates them), or (on Android) count the APK's native libraries through a `lib` link that the system may create in the data folder. + +Sizes are file lengths (`BasicFileAttributes.size()`). iOS uses allocated size, which has no portable equivalent on the JVM, and length is what the existing Database screen reports. + +## Verification + +- A standalone harness copying the walk logic, run against: + - a symlink loop and a link to `/`: not followed + - an unreadable folder: logged, the walk continues + - a nested folder and a duplicate folder: dropped + - a missing folder: shown as empty +- On a real 187 MB desktop profile, the walk took 0.8 s and matched `du -sb` minus the 4 KB that `du` also counts for each directory. +- A desktop AppImage run on a separate test profile showed all three folders, with sizes matching `ls`. + +Top-level entries are listed with `Files.newDirectoryStream` rather than `File.listFiles`, so each name stays a `Path` built from the raw bytes. Going through `String` and back with `File.toPath()` loses names that are not valid in the JVM's file-name encoding (any non-ASCII name under a C locale on Linux, e.g. a received file decrypted into `tmpDir`), which then either throws `InvalidPathException` or points to a file that does not exist. A root that cannot be listed (missing or unreadable) is logged and shown as empty. + +## Not included + +- The desktop Postgres build keeps its database on the Postgres server, so it is not in the total. +- Leaving the screen does not cancel a walk in progress. The result is simply discarded. From 7d8a17438843f2713680ec9960cc244f2383e63c Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:57:12 +0000 Subject: [PATCH 05/31] android: recognise passphrase fields as passwords for password managers (#7612) * android: recognise passphrase fields as passwords for password managers Passphrase fields used a hand-rolled VisualTransformation lambda, so Compose never marked them as password fields: the autofill structure carried no hints and no password inputType, and password managers could not identify them. The raw passphrase was also exposed through the CopyText/ACTION_COPY accessibility action. PassphraseField now declares an autofill content type and uses PasswordVisualTransformation, which sets the password semantics Compose derives that flag from. Fields that create a passphrase declare NewPassword. Where no field would carry plain password (first-time setup, hidden profile password) they declare both, as managers do not recognise a form whose every field is newPassword. On the change form the current-passphrase field supplies password, so the new fields declare only newPassword - adding password there leaves the manager unable to tell which field holds the new value and suppresses the save prompt. * android: do not offer to save hidden profile passwords Hidden profile password fields no longer declare an autofill content type. Compose commits the autofill session, which shows the password manager's save prompt, only after the last field with a content type leaves the screen, so these fields never trigger it. A saved entry would reveal that the hidden profile exists. Rewrite the plan to match the code. --- .../common/platform/Modifier.android.kt | 3 + .../chat/simplex/common/platform/Modifier.kt | 3 + .../views/database/DatabaseEncryptionView.kt | 14 ++++- .../onboarding/SetupDatabasePassphrase.kt | 3 + .../views/usersettings/HiddenProfileView.kt | 4 +- .../views/usersettings/UserProfilesView.kt | 2 +- .../common/platform/Modifier.desktop.kt | 3 + plans/2026-09-20-passphrase-autofill.md | 56 +++++++++++++++++++ 8 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 plans/2026-09-20-passphrase-autofill.md diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt index 5d07aae088..a4f931058c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -2,6 +2,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.graphics.painter.Painter import java.io.File @@ -18,3 +19,5 @@ actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this actual fun Modifier.desktopPointerHoverIconHand(): Modifier = this actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = Modifier + +actual fun newPasswordContentType(): ContentType = ContentType.NewPassword + ContentType.Password diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index caaf66811c..88d4709dfa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -3,6 +3,7 @@ package chat.simplex.common.platform import androidx.compose.foundation.combinedClickable import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.ImageBitmap @@ -127,3 +128,5 @@ private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_ while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2) return image } + +expect fun newPasswordContentType(): ContentType diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index ac64c93c73..8f22515370 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -25,6 +25,9 @@ import androidx.compose.ui.text.input.* import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs @@ -141,7 +144,10 @@ fun DatabaseEncryptionLayout( } } - if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) { + val hasCurrentField = !initialRandomDBPassphrase.value && chatDbEncrypted == true + val newKeyContentType = + if (hasCurrentField) ContentType.NewPassword else newPasswordContentType() + if (hasCurrentField) { PassphraseField( currentKey, generalGetString(MR.strings.current_passphrase), @@ -158,6 +164,7 @@ fun DatabaseEncryptionLayout( showStrength = true, isValid = ::validKey, keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + contentType = newKeyContentType, ) val onClickUpdate = { // Don't do things concurrently. Shouldn't be here concurrently, just in case @@ -187,6 +194,7 @@ fun DatabaseEncryptionLayout( generalGetString(MR.strings.confirm_new_passphrase), modifier = Modifier.padding(horizontal = DEFAULT_PADDING), isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, + contentType = newKeyContentType, keyboardActions = KeyboardActions(onDone = { if (!disabled) onClickUpdate() defaultKeyboardAction(ImeAction.Done) @@ -323,6 +331,7 @@ fun PassphraseField( keyboardActions: KeyboardActions = KeyboardActions(), dependsOn: State? = null, requestFocus: Boolean = false, + contentType: ContentType? = ContentType.Password, ) { var valid by remember { mutableStateOf(validKey(key.value)) } var showKey by remember { mutableStateOf(false) } @@ -362,6 +371,7 @@ fun PassphraseField( minWidth = TextFieldDefaults.MinWidth, minHeight = TextFieldDefaults.MinHeight ) + .semantics { if (contentType != null) this.contentType = contentType } .focusRequester(focusRequester), onValueChange = { state.value = it @@ -372,7 +382,7 @@ fun PassphraseField( visualTransformation = if (showKey) VisualTransformation.None else - VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) }, + PasswordVisualTransformation('*'), keyboardOptions = keyboardOptions, keyboardActions = KeyboardActions(onDone = { keyboard?.hide() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 0d00c20591..f198fe1f7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.focus.* import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalFocusManager @@ -152,6 +153,7 @@ private fun SetupDatabasePassphraseLayout( showStrength = true, isValid = ::validKey, keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + contentType = newPasswordContentType(), ) PassphraseField( @@ -169,6 +171,7 @@ private fun SetupDatabasePassphraseLayout( }, isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }), + contentType = newPasswordContentType(), ) } Spacer(Modifier.weight(1f)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index a14dab3f1a..8e96bec184 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -70,10 +70,10 @@ private fun HiddenProfileLayout( val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || !passwordValid || confirmHidePassword.value == "" || !confirmValid } } SectionView(stringResource(MR.strings.hidden_profile_password)) { SectionItemViewWithoutMinPadding { - PassphraseField(hidePassword, generalGetString(MR.strings.password_to_show), isValid = { passwordValid }, showStrength = true) + PassphraseField(hidePassword, generalGetString(MR.strings.password_to_show), isValid = { passwordValid }, showStrength = true, contentType = null) } SectionItemViewWithoutMinPadding { - PassphraseField(confirmHidePassword, stringResource(MR.strings.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword) + PassphraseField(confirmHidePassword, stringResource(MR.strings.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword, contentType = null) } SectionItemViewSpaceBetween({ saveProfilePassword(hidePassword.value) }, disabled = saveDisabled, minHeight = TextFieldDefaults.MinHeight) { Text(generalGetString(MR.strings.save_profile_password), color = if (saveDisabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index dd1e208349..e91d6c2ce0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -277,7 +277,7 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: ( @Composable fun PasswordAndAction(label: StringResource, color: Color = MaterialTheme.colors.primary) { SectionView() { SectionItemViewWithoutMinPadding { - PassphraseField(actionPassword, generalGetString(MR.strings.profile_password), isValid = { passwordValid }, showStrength = true) + PassphraseField(actionPassword, generalGetString(MR.strings.profile_password), isValid = { passwordValid }, showStrength = true, contentType = null) } SectionItemViewSpaceBetween({ doAction(actionPassword.value) }, disabled = !actionEnabled, minHeight = TextFieldDefaults.MinHeight) { Text(generalGetString(label), color = if (actionEnabled) color else MaterialTheme.colors.secondary) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt index b090e301d5..894f31a395 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.* +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.draganddrop.* import androidx.compose.ui.draganddrop.DragData import androidx.compose.ui.input.pointer.* @@ -85,3 +86,5 @@ actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = this then Modifier .onPointerEvent(PointerEventType.Enter) { action(true) } .onPointerEvent(PointerEventType.Exit) { action(false) } + +actual fun newPasswordContentType(): ContentType = ContentType.NewPassword diff --git a/plans/2026-09-20-passphrase-autofill.md b/plans/2026-09-20-passphrase-autofill.md new file mode 100644 index 0000000000..a763a444e6 --- /dev/null +++ b/plans/2026-09-20-passphrase-autofill.md @@ -0,0 +1,56 @@ +# Passphrase fields not recognised by password managers (Android) + +PR #7612, branch `nd/passphrase-autofill`. + +## Problem + +> The passphase isn't detected as a password box for any password manager so it refuses to autofill. + +`PassphraseField` (`DatabaseEncryptionView.kt`) was never classified as a password, so no manager offered to fill or save the database passphrase. While verifying this, a second defect turned up: the raw passphrase could be copied to the clipboard from the selection toolbar and through accessibility `ACTION_COPY`. + +## Root cause (Compose 1.8.2, from bytecode) + +- Semantic autofill is enabled (`ComposeUiFlags.isSemanticAutofillEnabled = true`). `PopulateViewStructure.populate` builds each node from semantics. It sets autofill hints only from `SemanticsProperties.ContentType`, and `inputType = 0x81` plus `dataIsSensitive` only from `password()` or a `"password"` hint. +- The legacy `BasicTextField` never sets `ContentType`. It sets `password()` only when `visualTransformation is PasswordVisualTransformation`. The hand-rolled `VisualTransformation { … }` lambda fails that check, so the field reached managers with no hints and no password `inputType`. +- The same `is PasswordVisualTransformation` check gates Copy/Cut in the selection toolbar and the `CopyText`/`CutText` accessibility actions. Both copy `value.getSelectedText()`, which is the raw passphrase. + +## Fix + +`PassphraseField`: +- uses `PasswordVisualTransformation('*')`. The mask looks the same, and it restores `password()` semantics and the copy/cut gates. +- takes `contentType: ContentType? = ContentType.Password` and applies it with `.semantics { if (contentType != null) this.contentType = contentType }`. + +`newPasswordContentType()` (expect/actual in `platform/Modifier*.kt`) returns `NewPassword + Password` on Android and `NewPassword` on desktop, where `ContentType` has no `plus`. Desktop has no OS autofill, so it is inert there. + +| Screen | Current | New | Confirm | +| --- | --- | --- | --- | +| Change passphrase (`DatabaseEncryptionView`, current field shown) | `password` | `newPassword` | `newPassword` | +| First passphrase (`DatabaseEncryptionView` with no current field, `SetupDatabasePassphrase`) | — | `newPassword`+`password` | `newPassword`+`password` | +| Unlock / migrate (`DatabaseErrorView`, `MigrateToDevice`, `MigrateFromDevice`) | `password` | — | — | +| Hidden profile password (`HiddenProfileView`), unhide/delete (`UserProfilesView`) | none | none | none | + +Why the hints vary: +- **First passphrase:** KeePassDX does not recognise a form whose every field is `newPassword`, so these fields also carry `password`. +- **Change form:** adding `password` to the new fields here gives three identical `password` fields, and the manager stops offering to save. +- **Hidden profile:** these fields carry no `ContentType`, so no password manager offers to save them. A stored entry would reveal that the hidden profile exists. `AndroidAutofillManager.onEndApplyChanges` commits the session, which raises the save prompt, only after the last node carrying `ContentType` leaves the screen (`isRelatedToAutoCommit` = has `ContentType`), so leaving these screens never commits. Compose 1.8.2 cannot mark a node not important for autofill (`populate` never calls `setImportantForAutofill`). The fields still carry `password()`, so a manager may still offer to fill them. + +The app never calls `AutofillManager.commit()`. Committing on submit was tried and reverted: it ended the session while the fields were still shown, and autofill stayed dead until the app restarted. + +## Verification + +- **Semantics** (desktop Compose test `~/passphrase-review/semtest`, a two-field A/B): with the fix the node has `ContentType`, `Password = true`, and no `CopyText`. Without the fix it has no `ContentType`, `Password = false`, and `CopyText` present. `EditableText` is masked either way; autofill never received the raw value. +- **Device, structure** (stub `AutofillService` dumping `AssistStructure`, `~/passphrase-review/harness`, on an earlier `newPassword`-only build): the passphrase fields arrived as `EditText` with `inputType=0x81` and their hints. +- **Device, KeePassDX:** the hint combinations in the table were settled by testing with KeePassDX. It fills and offers to save on the change and first-passphrase forms, and the save prompt appears when the screen is closed. +- **Not yet checked on a device:** the hidden-profile screens (no `ContentType`) showing no save prompt. + +## Limitations + +- KeePassDX offers to fill only the last password field in the form (Confirm rather than New). New and Confirm emit identical hints and `inputType`, so this has to be fixed in KeePassDX. +- The framework still commits when the activity finishes (`AutofillManager.onActivityFinishing`) unless the service set `FLAG_DONT_SAVE_ON_FINISH`. A session opened by focusing a hidden-profile field could therefore still prompt to save if the activity finishes before that session ends. +- With a hardware keyboard or mouse, Ctrl+C and the Android right-click menu still copy, because those paths don't check the transformation (an upstream Compose gap). Only `BasicSecureTextField` blocks copying outright. +- The Compose text-toolbar "Autofill" item does nothing for the legacy `BasicTextField`, because `requestAutofillAction` is never assigned. Also upstream. + +## Out of scope + +- `DefaultConfigurableTextField` (`DefaultBasicTextField.kt:163–166`), which backs the SOCKS proxy password (`NetworkAndServers.kt:525`), has the same lambda and no `ContentType`. It has the same clipboard exposure, and the same two-line fix applies. +- iOS `PassphraseField` (`DatabaseEncryptionView.swift:304`) is a `SecureField` without `.textContentType`, which is the same gap. From d457bc18d22617f8111817f7e0fecdcad01453f8 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:38:14 +0000 Subject: [PATCH 06/31] ios: delete temp data from storage view (#7626) * ios: delete temp data from storage view The temp folder is the XFTP agent work directory and holds scratch files for received-file encryption and roster transfers, so deleting it under a running chat breaks those writes mid-flight. The button is only enabled while chat is stopped, the same precondition core deleteStorage relies on, and the row says how to stop chat. * ios: show delete temp data alert over settings sheet AlertManager presents from ContentView, which is behind the settings sheet, so the confirmation never appeared. The alert now also warns that interrupted transfers never complete and that videos in unsent drafts, which live in the temp folder, are lost, and the caption names the screen where chat is stopped. * ios: say unsent draft videos are sent without the file A draft video whose converted file was deleted from the temp folder is still sent, as a preview with no file, so "lost" understated it. --------- Co-authored-by: Evgeny Poberezkin --- .../Views/UserSettings/StorageView.swift | 49 ++++++++++++++++--- apps/ios/SimpleXChat/FileUtils.swift | 10 ++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/ios/Shared/Views/UserSettings/StorageView.swift b/apps/ios/Shared/Views/UserSettings/StorageView.swift index 094c1cb3d6..9d88e02be2 100644 --- a/apps/ios/Shared/Views/UserSettings/StorageView.swift +++ b/apps/ios/Shared/Views/UserSettings/StorageView.swift @@ -10,33 +10,66 @@ import SwiftUI import SimpleXChat struct StorageView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme @State var appGroupFiles: [String: Int64] = [:] @State var documentsFiles: [String: Int64] = [:] var body: some View { ScrollView { VStack(alignment: .leading) { - directoryView("App group:", appGroupFiles) + directoryView("App group:", getGroupContainerDirectory(), appGroupFiles) if !documentsFiles.isEmpty { - directoryView("Documents:", documentsFiles) + directoryView("Documents:", getDocumentsDirectory(), documentsFiles) } } } .padding() - .onAppear { - appGroupFiles = traverseFiles(in: getGroupContainerDirectory()) - documentsFiles = traverseFiles(in: getDocumentsDirectory()) - } + .onAppear(perform: loadFiles) + } + + private func loadFiles() { + appGroupFiles = traverseFiles(in: getGroupContainerDirectory()) + documentsFiles = traverseFiles(in: getDocumentsDirectory()) } @ViewBuilder - private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View { + private func directoryView(_ name: LocalizedStringKey, _ dir: URL, _ contents: [String: Int64]) -> some View { Text(name).font(.headline) ForEach(Array(contents), id: \.key) { (key, value) in - Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))) + let sizeText = Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))) + if dir.appendingPathComponent(key).path == getTempFilesDirectory().path { + let stopped = m.chatRunning == false + HStack { + sizeText + Button("Delete temp data", role: .destructive, action: confirmDeleteTempFiles) + .disabled(!stopped) + } + if !stopped { + Text("Stop chat in Settings → Chat data → Database passphrase & export to delete temp data.") + .font(.footnote) + .foregroundColor(theme.colors.secondary) + } + } else { + sizeText + } } } + private func confirmDeleteTempFiles() { + showAlert( + NSLocalizedString("Delete temp data?", comment: "alert title"), + message: NSLocalizedString("Files being sent or received will never complete. Videos in unsent drafts will be sent without the file.", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Delete", comment: "alert action"), style: .destructive) { _ in + deleteTempFiles() + loadFiles() + }, + cancelAlertAction + ]} + ) + } + private func traverseFiles(in dir: URL) -> [String: Int64] { var res: [String: Int64] = [:] let fm = FileManager.default diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 1641541ef7..ba40a6817d 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -140,6 +140,16 @@ public func deleteAppFiles() { } } +public func deleteTempFiles() { + let fm = FileManager.default + do { + try fm.removeItem(at: getTempFilesDirectory()) + try fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true) + } catch { + logger.error("FileUtils deleteTempFiles error: \(error.localizedDescription)") + } +} + public func fileSize(_ url: URL) -> Int? { // in bytes do { let val = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]) From 1053e9fb5c20ffedfddbc11e6995ce30fd7f7743 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 10 Oct 2026 23:30:31 +0100 Subject: [PATCH 07/31] core: use simpler agent api (#7685) * core: use simpler agent api * simplexmq * simplexmq, plan --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- cabal.project | 2 +- ...026-09-04-badge-binding-and-file-limits.md | 79 ++++++++++++------- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 18 ++--- src/Simplex/Chat/Library/Subscriber.hs | 4 +- 5 files changed, 61 insertions(+), 44 deletions(-) diff --git a/cabal.project b/cabal.project index 2614f4332d..9d5f1b8e21 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: ee66dd68d4429e769da7f2506a48af5887e0f7af + tag: a2f985dc68f870c0327fc3e871f2816828bcba2d source-repository-package type: git diff --git a/plans/2026-09-04-badge-binding-and-file-limits.md b/plans/2026-09-04-badge-binding-and-file-limits.md index 2783064a88..29d6905b85 100644 --- a/plans/2026-09-04-badge-binding-and-file-limits.md +++ b/plans/2026-09-04-badge-binding-and-file-limits.md @@ -343,45 +343,65 @@ Files: `Agent.hs`, `Agent/Client.hs`, `Agent/Protocol.hs`, `Crypto/ShortLink.hs` The chat pins the simplexmq commit in `cabal.project` and `scripts/nix/sha256map.nix`. -A link prepared by `prepareConnectionLink` is available to the chat before its link data is signed: a contact link with its short link, an invitation as its full link and its link key; one network request is made per creation. +A link is available to the chat before its link data is signed: a contact link with its short link from `prepareConnectionLink`, an invitation as its full link with its link key from `prepareInvitationLink`. One network request is made per creation. -**New links.** The contact link with its short link, or the invitation with its link key `plpLinkKey`, is returned by `prepareConnectionLink` before the user data is signed; the short link of an invitation, with the link id assigned by the server, is returned by `createConnectionForLink`: +**New links.** The short link of an invitation, with the link id assigned by the server, is returned by `createInvitationForLink`: ```haskell -prepareConnectionLink :: AgentClient -> UserId -> SConnectionMode c -> C.KeyPairEd25519 -> Maybe ByteString -> Bool -> Maybe CRClientData -> CR.InitialKeys -> UseRatchetKeys -> Maybe SMPServerWithAuth -> AE (CreatedConnLink c, PreparedLinkParams c) +prepareConnectionLink :: AgentClient -> UserId -> C.KeyPairEd25519 -> ByteString -> Bool -> Maybe CRClientData -> CR.InitialKeys -> UseRatchetKeys -> Maybe SMPServerWithAuth -> AE (CreatedConnLink 'CMContact, PreparedLinkParams (Maybe (RatchetKeyId, CR.RcvE2EPrivRatchetParams 'C.X448))) -createConnectionForLink :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> CreatedConnLink c -> PreparedLinkParams c -> UserConnLinkData c -> SubscriptionMode -> AE (ConnId, CreatedConnLink c) +createConnectionForLink :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> CreatedConnLink 'CMContact -> PreparedLinkParams (Maybe (RatchetKeyId, CR.RcvE2EPrivRatchetParams 'C.X448)) -> UserConnLinkData 'CMContact -> SubscriptionMode -> AE ConnId + +prepareInvitationLink :: AgentClient -> UserId -> CR.InitialKeys -> Maybe SMPServerWithAuth -> AE (ConnectionRequestUri 'CMInvitation, PreparedLinkParams (CR.RcvE2EPrivRatchetParams 'C.X448)) + +createInvitationForLink :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> ConnectionRequestUri 'CMInvitation -> PreparedLinkParams (CR.RcvE2EPrivRatchetParams 'C.X448) -> UserConnLinkData 'CMInvitation -> SubscriptionMode -> AE (ConnId, CreatedConnLink 'CMInvitation) ``` -- The link entity id is optional; `Nothing` for an invitation. -- `PreparedLinkParams` is indexed by the connection mode. The ratchet keys are one field, `plpRatchetKeys :: PreparedRatchetKeys m`, in place of `plpAddressKeys`: +- The contact functions keep the signatures of master, with the type parameter of `PreparedLinkParams`. +- `PreparedLinkParams` is parameterised by the keys stored at creation; `plpAddressKeys` is renamed `plpRatchetKeys`: ```haskell - data PreparedRatchetKeys (m :: ConnectionMode) where - PRKInvitation :: RcvE2EPrivRatchetParams 'C.X448 -> PreparedRatchetKeys 'CMInvitation - PRKContact :: Maybe (RatchetKeyId, RcvE2EPrivRatchetParams 'C.X448) -> PreparedRatchetKeys 'CMContact + data PreparedLinkParams k = PreparedLinkParams + { plpNonce :: C.CbNonce, + plpQueueE2EKeys :: C.KeyPairX25519, + plpLinkKey :: LinkKey, + plpRootPrivKey :: C.PrivateKeyEd25519, + plpSignedFixedData :: ByteString, + plpSrvWithAuth :: SMPServerWithAuth, + plpInitKeys :: InitialKeys, + plpRatchetKeys :: k + } ``` -- Client notices are checked for contact mode only. -- Contact mode: the link given to `createConnectionForLink` is returned. -- Invitation mode, prepare: - - x3dh keys from `CR.generateRcvE2EParams`, PQ support from `CR.initialPQEncryption True pqInitKeys` - - `connReq = CRInvitationUri crData e2eRcvParams`, queue mode `QMMessaging` - - the returned link is `CCLink connReq Nothing`; the key is `plpLinkKey` - - `useDR` is ignored -- Invitation mode, create: - - link data: `SL.encodeSignUserData SCMInvitation`, encrypted by `encryptInvLinkData` with `SL.invShortLinkKdf plpLinkKey`; `newRcvConnSrv` uses the same function - - queue request: `CQRMessaging (Just CQRData {linkKey, privSigKey, srvReq = (sndId, srvData)})` -- In both modes the link data is encrypted before the connection is created, and the queue is created by the local `createLinkQueue`: - - the ratchet keys are stored: `PRKContact` keys by `storeAddressRatchetKeys`, `PRKInvitation` keys by `createRatchetX3dhKeys` - - `createRcvQueue` - - the connection is deleted when either step fails - - the returned link from `connReqWithShortLink`, moved from `newRcvConnSrv` to top level, with the created `RcvQueue` as a parameter — `CSLInvitation` with the link id from the server, PQ keys removed from the full link for `IKPQOn` + - contact link: `k = Maybe (RatchetKeyId, RcvE2EPrivRatchetParams 'C.X448)`, the address ratchet keys + - invitation: `k = RcvE2EPrivRatchetParams 'C.X448`, the X3DH keys +- Prepare, `prepareLinkParams :: ConnectionModeI c => C.KeyPairEd25519 -> Maybe ByteString -> Maybe CRClientData -> CR.InitialKeys -> SMPServerWithAuth -> QueueMode -> (ConnReqUriData -> ConnectionRequestUri c) -> k -> AM (ConnectionRequestUri c, PreparedLinkParams k)`: + - the nonce and the queue keys are generated; + - the connection request is built by the given function from the queue of the given mode; + - its fixed data is signed with the root key and the entity id. +- `prepareConnectionLink'`: the server is chosen; client notices are checked when `checkNotices` is set; the address ratchet keys are generated when `useDR` is set; `QMContact`. +- `prepareInvitationLink'`: + - the server is chosen and the root key pair is generated; + - X3DH keys from `CR.generateRcvE2EParams`, PQ support from `CR.initialPQEncryption True pqInitKeys`; + - `CRInvitationUri` with the X3DH parameters, `QMMessaging`; + - the entity id and the client data are `Nothing`. +- Create, `createLinkQueue :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> SConnectionMode c -> PreparedLinkParams k -> SubscriptionMode -> ClntQueueReqData -> (ConnId -> AM ()) -> AM (ConnId, RcvQueue, SMPQueueUri)`: + - the connection is created; + - the keys are stored by the given action; + - the queue is created by `createRcvQueue`; + - the connection is deleted when either step fails. +- `createConnectionForLink'`: + - the link data is encrypted by `encryptContactLinkData` before the connection is created; + - the address ratchet keys are stored by `storeAddressRatchetKeys`; + - the sender id is compared after `NEW`, as on master. +- `createInvitationForLink'`: + - link data: `SL.encodeSignUserData SCMInvitation`, encrypted with `SL.invShortLinkKdf plpLinkKey`; + - queue request: `CQRMessaging (Just CQRData {linkKey, privSigKey, srvReq = (sndId, srvData)})`; + - the X3DH keys are stored by `createRatchetX3dhKeys`; + - the returned link from `connReqWithShortLink`: `CSLInvitation` with the link id from the server, PQ keys removed from the full link for `IKPQOn`. - Tests: - - a connection via an invitation made by prepare and create; its link data is read by the joining party; `plpLinkKey` equals the key in the returned `CSLInvitation` - - a contact link returned by `createConnectionForLink` equals the prepared link - - for link data above the size limit, `CMD LARGE` is returned in both modes and no connection is stored - - the six existing test calls are updated for the mode and the pair + - a connection via an invitation made by prepare and create: its link data is read by the joining party and holds the prepared invitation; `plpLinkKey` equals the key in the returned `CSLInvitation`; for `IKPQOn` the returned full link differs from the prepared invitation + - for link data above the size limit, `CMD LARGE` is returned for a contact link and for an invitation, and no connection is stored **Existing connections.** The link of a connection is returned without a network call: @@ -399,7 +419,6 @@ prepareConnShortLink :: AgentClient -> ConnId -> Maybe CRClientData -> AE (ConnS - `NEW` and `IDS` (`Protocol.hs`): the encodings and parsers for versions below 15, with `qReq` and `qm`. - `mkShortLinkCreds` (`Agent/Client.hs`): link data without a link id in the response is an error; the `THandleParams` parameter is removed. - `connReqWithShortLink`: absent short link credentials are an `INTERNAL` error. -- `createConnectionForLink'`: the sender id check of master, error `sender ID mismatch`, is removed; the sender id is compared by `mkShortLinkCreds` and by `connReqWithShortLink`. - `Transport.hs`: `shortLinksSMPVersion` is renamed `_shortLinksSMPVersion` and is not exported; `_proxyServerHandshakeSMPVersion` is removed. - Tests: `testInvitationShortLinkPrev` and `testProxyMatrixWithPrev` are removed. @@ -524,7 +543,7 @@ ALTER TABLE connections ADD COLUMN pres_header BLOB; **10. Link data** -- Invitation links (`APIAddContact`, `recreateConn`): the chat generates the root key pair and calls `prepareConnectionLink SCMInvitation`; the badge is presented with `PHLink` of `plpLinkKey`; then `createConnectionForLink` is called. +- Invitation links (`APIAddContact`, `recreateConn`): the chat calls `prepareInvitationLink`; the badge is presented with `PHLink` of `plpLinkKey`; then `createInvitationForLink` is called with the invitation and the params. - Invitation updates (`updatePCCShortLinkData`, `APISetConnectionIncognito`): `linkPresHeader` of the stored invitation short link. - Address (`APICreateMyAddress`): `linkPresHeader` of the key in the prepared `CSLContact`. - Address updates (`setMyAddressData`): the key of the stored short link. For an address without a short link, the key is taken from `prepareConnShortLink` when a badge is presented, then `setConnShortLink` is called once. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 2e0ecfead3..43573e82cf 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ee66dd68d4429e769da7f2506a48af5887e0f7af" = "0k58ayni6d36fjcahj8lvwkmg6yabmv60g8va6hh9z2ph09lhj2i"; + "https://github.com/simplex-chat/simplexmq.git"."a2f985dc68f870c0327fc3e871f2816828bcba2d" = "12a66936nliggq8k2pmk8x2lilrhvlg15rsmcv556s8gpzxjavdw"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."2330df3dc8c4b660674d3c02d76bb86f66c2abee" = "11pfj1vqsjdl26nplkr2l6z1xj7cn153qijj5gknmlzq3krn7x1i"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f1b2ad3f7a..1c0510c31d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2124,12 +2124,11 @@ processChatCommand cxt nm = \case -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode - rootKey <- atomically . C.generateKeyPair =<< asks random - (preparedLink, preparedParams@PreparedLinkParams {plpLinkKey = LinkKey linkKey}) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) SCMInvitation rootKey Nothing False Nothing IKUsePQ False Nothing + (preparedReq, preparedParams@PreparedLinkParams {plpLinkKey = LinkKey linkKey}) <- withAgent $ \a -> prepareInvitationLink a (aUserId user) IKUsePQ Nothing linkProfile <- presentUserBadge user incognitoProfile (Just $ PHLink linkKey) $ userProfileDirect user incognitoProfile Nothing True let userData = contactShortLinkData linkProfile {contactDomain = Nothing} Nothing userLinkData = UserInvLinkData userData - (connId, ccLink) <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True preparedLink preparedParams userLinkData subMode + (connId, ccLink) <- withAgent $ \a -> createInvitationForLink a nm (aUserId user) True preparedReq preparedParams userLinkData 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 @@ -2170,10 +2169,9 @@ processChatCommand cxt nm = \case (agConnId, ccLink) <- if short then do - rootKey <- atomically . C.generateKeyPair =<< asks random - (preparedLink, preparedParams@PreparedLinkParams {plpLinkKey = LinkKey linkKey}) <- withAgent $ \a -> prepareConnectionLink a (aUserId newUser) SCMInvitation rootKey Nothing False Nothing IKPQOn False Nothing + (preparedReq, preparedParams@PreparedLinkParams {plpLinkKey = LinkKey linkKey}) <- withAgent $ \a -> prepareInvitationLink a (aUserId newUser) IKPQOn Nothing userLinkData <- UserInvLinkData . (`contactShortLinkData` Nothing) <$> presentUserBadge newUser Nothing (Just $ PHLink linkKey) (userProfileDirect newUser Nothing Nothing True) - withAgent $ \a -> createConnectionForLink a nm (aUserId newUser) True preparedLink preparedParams userLinkData subMode + withAgent $ \a -> createInvitationForLink a nm (aUserId newUser) True preparedReq preparedParams userLinkData subMode else withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation Nothing Nothing IKPQOn True subMode ccLink' <- shortenCreatedLink ccLink conn' <- withFastStore' $ \db -> do @@ -2472,7 +2470,7 @@ processChatCommand cxt nm = \case Just True -> (IKUsePQ, True) Just False -> (IKPQOn, True) Nothing -> (IKPQOn, False) - (ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) SCMContact rootKey (Just entityId) True Nothing pqInitKeys useDR server_ + (ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) rootKey entityId True Nothing pqInitKeys useDR server_ ccLink' <- shortenCreatedLink ccLink -- TODO [relays] relay: add identity, key to link data? userData <- @@ -2480,7 +2478,7 @@ processChatCommand cxt nm = \case then pure $ relayShortLinkData (userProfileDirect user Nothing Nothing True) else (`contactShortLinkData` Nothing) <$> presentUserBadge user Nothing (linkPresHeader <$> connShortLink' ccLink) (userProfileDirect user Nothing Nothing True) let userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData, ratchetKeys = Nothing} - (connId, _) <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode + connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode let ccLink'' = if isTrue userChatRelay then setShortLinkType CCTRelay ccLink' else ccLink' withFastStore $ \db -> createUserContactLink db user connId ccLink'' subMode rootPrivKey pure $ CRUserContactLinkCreated user ccLink'' @@ -2749,7 +2747,7 @@ processChatCommand cxt nm = \case let entityId = C.sha256Hash $ C.pubKeyBytes rootPubKey crClientData = encodeJSON $ CRDataGroup groupLinkId -- prepare link with entityId as linkEntityId (no server request) - (ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) SCMContact rootKey (Just entityId) True (Just crClientData) IKPQOff False Nothing + (ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) rootKey entityId True (Just crClientData) IKPQOff False Nothing ccLink' <- setShortLinkType CCTChannel <$> shortenCreatedLink ccLink sLnk <- case connShortLink' ccLink' of Just sl -> pure sl @@ -2762,7 +2760,7 @@ processChatCommand cxt nm = \case userData = encodeShortLinkData $ GroupShortLinkData {groupProfile = groupProfile', publicGroupData = Just (PublicGroupData 1)} userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData, ratchetKeys = Nothing} -- create connection with prepared link (single network call) - (connId, _) <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode + connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode let groupKeys = GKPublicGroup {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} setupLink gInfo = do -- TODO [relays] starting role should be communicated in protocol from owner to relays diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 3631331f75..a07a8c754b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -4586,7 +4586,7 @@ runRelayRequestWorker a Worker {doWork} = do subMode <- chatReadVar subscriptionMode let crClientData = encodeJSON $ CRDataGroup groupLinkId -- prepare link with relayMemId as linkEntityId (no server request) - (ccLink, preparedParams) <- withAgent $ \a' -> prepareConnectionLink a' (aUserId user) SCMContact sigKeys (Just relayMemId) True (Just crClientData) CR.IKPQOff False Nothing + (ccLink, preparedParams) <- withAgent $ \a' -> prepareConnectionLink a' (aUserId user) sigKeys relayMemId True (Just crClientData) CR.IKPQOff False Nothing ccLink' <- setShortLinkType CCTGroup <$> shortenCreatedLink ccLink sLnk <- case connShortLink' ccLink' of Just sl -> pure sl @@ -4594,7 +4594,7 @@ runRelayRequestWorker a Worker {doWork} = do let userData = encodeShortLinkData $ RelayShortLinkData {relayProfile = fromLocalProfile p} userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData, ratchetKeys = Nothing} -- create connection with prepared link (single network call) - (connId, _) <- withAgent $ \a' -> createConnectionForLink a' NRMBackground (aUserId user) True ccLink preparedParams userLinkData subMode + connId <- withAgent $ \a' -> createConnectionForLink a' NRMBackground (aUserId user) True ccLink preparedParams userLinkData subMode -- TODO [relays] starting role should be communicated in protocol from owner to relays subRole <- asks $ channelSubscriberRole . config void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId subRole subMode From 07c363849b18638ee89de9fab5bdaed059fb8bc8 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 11 Oct 2026 09:50:42 +0100 Subject: [PATCH 08/31] core, ui: invite member of one group to another group (#7682) * rfc: invite member of one group to another group * update * update * implement * version * update * do not show error if contact was removed when joining group --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/AppAPITypes.swift | 3 + apps/ios/Shared/Model/SimpleXAPI.swift | 3 +- .../Chat/ChatItem/CIGroupInvitationView.swift | 7 + .../chat/simplex/common/model/SimpleXAPI.kt | 10 + .../views/chat/item/CIGroupInvitationView.kt | 4 + .../common/views/chat/item/ChatItemView.kt | 4 +- .../moko-resources/base/strings.xml | 2 + bots/api/COMMANDS.md | 47 ++ bots/api/EVENTS.md | 17 + bots/src/API/Docs/Commands.hs | 1 + bots/src/API/Docs/Events.hs | 1 + bots/src/API/Docs/Responses.hs | 1 + docs/protocol/simplex-chat.md | 2 + .../2026-10-10-invite-member-via-group.md | 204 ++++++ .../types/typescript/src/commands.ts | 17 + .../types/typescript/src/events.ts | 12 + .../types/typescript/src/responses.ts | 11 + .../typescript/src/client.ts | 6 + packages/simplex-chat-nodejs/src/api.ts | 10 + .../src/simplex_chat/api.py | 17 + .../src/simplex_chat/types/_commands.py | 15 + .../src/simplex_chat/types/_events.py | 18 +- .../src/simplex_chat/types/_responses.py | 11 +- plans/2026-10-10-invite-member-via-group.md | 612 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Controller.hs | 3 + src/Simplex/Chat/Library/Commands.hs | 82 ++- src/Simplex/Chat/Library/Subscriber.hs | 68 +- src/Simplex/Chat/Protocol.hs | 6 +- src/Simplex/Chat/Store/Groups.hs | 74 ++- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20261010_invite_member_via_group.hs | 21 + .../Store/Postgres/Migrations/chat_schema.sql | 12 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20261010_invite_member_via_group.hs | 20 + .../SQLite/Migrations/chat_query_plans.txt | 49 +- .../Store/SQLite/Migrations/chat_schema.sql | 4 + src/Simplex/Chat/Terminal/Input.hs | 1 + src/Simplex/Chat/Terminal/Output.hs | 3 + src/Simplex/Chat/View.hs | 12 +- tests/ChatTests/Groups.hs | 223 +++++++ 41 files changed, 1571 insertions(+), 52 deletions(-) create mode 100644 docs/rfcs/2026-10-10-invite-member-via-group.md create mode 100644 plans/2026-10-10-invite-member-via-group.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 26438bc60e..6d22acc232 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1191,6 +1191,7 @@ enum ChatEvent: Decodable, ChatAPIResult { // group events case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) + case receivedGroupInvitationViaGroup(user: UserRef, groupInfo: GroupInfo, viaGroupInfo: GroupInfo, viaMember: GroupMember, memberRole: GroupMemberRole) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) @@ -1272,6 +1273,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .chatItemsDeleted: "chatItemsDeleted" case .groupChatItemsDeleted: "groupChatItemsDeleted" case .receivedGroupInvitation: "receivedGroupInvitation" + case .receivedGroupInvitationViaGroup: "receivedGroupInvitationViaGroup" case .userAcceptedGroupSent: "userAcceptedGroupSent" case .groupLinkConnecting: "groupLinkConnecting" case .businessLinkConnecting: "businessLinkConnecting" @@ -1356,6 +1358,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") + case let .receivedGroupInvitationViaGroup(u, groupInfo, viaGroupInfo, viaMember, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nviaGroupInfo: \(viaGroupInfo)\nviaMember: \(viaMember)\nmemberRole: \(memberRole)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 21335cee64..d04ccf5b03 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2705,7 +2705,8 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_): await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_) - case let .receivedGroupInvitation(user, groupInfo, _, _): + case let .receivedGroupInvitation(user, groupInfo, _, _), + let .receivedGroupInvitationViaGroup(user, groupInfo, _, _, _): if active(user) { await MainActor.run { m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index ddb58fdfd1..fecd1ba891 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -39,6 +39,13 @@ struct CIGroupInvitationView: View { Divider().frame(width: frameWidth) + if chat.chatInfo.groupInfo != nil { + Text(chatItem.chatDir.sent ? String.localizedStringWithFormat(NSLocalizedString("For %@ only", comment: ""), groupInvitation.localDisplayName) : NSLocalizedString("For you only", comment: "")) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .overlay(DetermineWidth()) + } + if action { VStack(alignment: .leading, spacing: 2) { groupInvitationText() 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 5f1603423a..14bb9304d8 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 @@ -3128,6 +3128,13 @@ object ChatController { // TODO NtfManager.shared.notifyGroupInvitation } } + is CR.ReceivedGroupInvitationViaGroup -> { + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + } + } + } is CR.UserAcceptedGroupSent -> { if (!active(r.user)) return @@ -6815,6 +6822,7 @@ sealed class CR { @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("receivedGroupInvitationViaGroup") class ReceivedGroupInvitationViaGroup(val user: UserRef, val groupInfo: GroupInfo, val viaGroupInfo: GroupInfo, val viaMember: GroupMember, val memberRole: GroupMemberRole): 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() @@ -7015,6 +7023,7 @@ sealed class CR { is LeftMemberUser -> "leftMemberUser" is GroupMembers -> "groupMembers" is ReceivedGroupInvitation -> "receivedGroupInvitation" + is ReceivedGroupInvitationViaGroup -> "receivedGroupInvitationViaGroup" is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberAccepted -> "memberAccepted" @@ -7206,6 +7215,7 @@ sealed class CR { is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") + is ReceivedGroupInvitationViaGroup -> withUser(user, "groupInfo: $groupInfo\nviaGroupInfo: $viaGroupInfo\nviaMember: $viaMember\nmemberRole: $memberRole") 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") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 649188502d..14ecd723b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -29,6 +29,7 @@ fun CIGroupInvitationView( memberRole: GroupMemberRole, showTimestamp: Boolean, chatIncognito: Boolean = false, + groupChat: Boolean = false, joinGroup: (Long, () -> Unit) -> Unit, timedMessagesTTL: Int? ) { @@ -111,6 +112,9 @@ fun CIGroupInvitationView( val secondaryColor = MaterialTheme.colors.secondary Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (groupChat) { + Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor) + } if (action) { Text(groupInvitationStr(), fontSize = 13.sp, lineHeight = 18.sp) Text( 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 cb3a07b7f9..822aef9aa2 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 @@ -720,11 +720,11 @@ fun ChatItemView( DeleteItemMenu() } is CIContent.RcvGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, groupChat = cInfo is ChatInfo.Group, 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) + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, groupChat = cInfo is ChatInfo.Group, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) DeleteItemMenu() } is CIContent.RcvDirectEventContent -> { diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 3a1720d883..93bf4f5838 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -1769,6 +1769,8 @@ You joined this group You rejected group invitation Group invitation expired + For you only + For %1$s only deleted contact diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index af7b0146f7..96fe5422cd 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -25,6 +25,7 @@ This file is generated automatically. [Group commands](#group-commands) - [APIAddMember](#apiaddmember) +- [APIAddMemberViaGroup](#apiaddmemberviagroup) - [APIJoinGroup](#apijoingroup) - [APIAcceptMember](#apiacceptmember) - [APIMembersRole](#apimembersrole) @@ -761,6 +762,52 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APIAddMemberViaGroup + +Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. + +*Network usage*: interactive. + +**Parameters**: +- groupId: int64 +- viaGroupId: int64 +- groupMemberId: int64 +- memberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) + +**Syntax**: + +``` +/_add # # relay|observer|author|member|moderator|admin|owner +``` + +```javascript +'/_add #' + groupId + ' #' + viaGroupId + ' ' + groupMemberId + ' ' + memberRole // JavaScript +``` + +```python +'/_add #' + str(groupId) + ' #' + str(viaGroupId) + ' ' + str(groupMemberId) + ' ' + str(memberRole) # Python +``` + +**Responses**: + +SentGroupInvitationViaGroup: Group invitation sent to member of another group. +- type: "sentGroupInvitationViaGroup" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- member: [GroupMember](./TYPES.md#groupmember) +- viaGroupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaMember: [GroupMember](./TYPES.md#groupmember) + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +**Errors**: +- PeerChatVRangeIncompatible: Member's client version is older than required. + +--- + + ### APIJoinGroup Join group. diff --git a/bots/api/EVENTS.md b/bots/api/EVENTS.md index 5416fe4c0e..7c43ce3bd4 100644 --- a/bots/api/EVENTS.md +++ b/bots/api/EVENTS.md @@ -25,6 +25,7 @@ This file is generated automatically. [Group events](#group-events) - Main events - [ReceivedGroupInvitation](#receivedgroupinvitation) + - [ReceivedGroupInvitationViaGroup](#receivedgroupinvitationviagroup) - [UserJoinedGroup](#userjoinedgroup) - [GroupUpdated](#groupupdated) - [JoinedGroupMember](#joinedgroupmember) @@ -286,6 +287,22 @@ Received group invitation. --- +### ReceivedGroupInvitationViaGroup + +Received group invitation from member of another group. + +**Record type**: +- type: "receivedGroupInvitationViaGroup" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaGroupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaMember: [GroupMember](./TYPES.md#groupmember) +- fromMemberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +- memberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) + +--- + + ### UserJoinedGroup Bot user joined group. Received when connection via group link completes. diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 79820605a8..5804b0e945 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -119,6 +119,7 @@ chatCommandsDocsData = ( "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", "CRChatCmdError"], [], Just UNInteractive, "/_add #" <> Param "groupId" <> " " <> Param "contactId" <> " " <> Param "memberRole"), + ("APIAddMemberViaGroup", [], "Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group.", ["CRSentGroupInvitationViaGroup", "CRChatCmdError"], [TD "CEPeerChatVRangeIncompatible" "Member's client version is older than required"], Just UNInteractive, "/_add #" <> Param "groupId" <> " #" <> Param "viaGroupId" <> " " <> Param "groupMemberId" <> " " <> 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", "CRChatCmdError"], [], Just UNBackground, "/_member role #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> " " <> Param "memberRole"), diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index dab06e9262..48f307f6e4 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -85,6 +85,7 @@ chatEventsDocsData = "Bots may use these events to manage users' groups and business address groups.\n\n\ \*Please note*: programming groups is more complex than programming direct connections", [ ("CEvtReceivedGroupInvitation", ""), + ("CEvtReceivedGroupInvitationViaGroup", "Received group invitation from member of another group."), ("CEvtUserJoinedGroup", "Bot user joined group. Received when connection via group link completes."), ("CEvtGroupUpdated", "Group profile or preferences updated."), ("CEvtJoinedGroupMember", "Another member joined group."), diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 18392ae1b5..c97e96e302 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -92,6 +92,7 @@ chatResponsesDocsData = ("CRRemoteCtrlConnecting", "Remote controller is connecting."), ("CRSentConfirmation", "Confirmation sent to one-time invitation"), ("CRSentGroupInvitation", "Group invitation sent"), + ("CRSentGroupInvitationViaGroup", "Group invitation sent to member of another group"), ("CRSentInvitation", "Invitation sent to contact address"), ("CRSentInvitationToContact", "Invitation sent to contact (when connecting via SimpleX name to a known contact address)."), ("CRServiceReplyAccepted", "Service reply accepted for delivery. `connectionId` correlates the reply delivery event."), diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index aa1c889528..8cd655d0c0 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -232,6 +232,8 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.inv` message is sent to invite contact to the group via contact's direct connection and includes group member connection address. This message MUST only be sent by members with `admin` or `owner` role. Optional `groupLinkId` is included when this message is sent to contacts connected via the user's group link. This identifier is a random byte sequence, with no global or even local uniqueness - it is only used for the user's invitations to a given group to provide confirmation to the contact that the group invitation is for the same group the contact was connecting to via the group link, so that the invitation can be automatically accepted by the contact - the contact compares it with the group link id contained in the group link uri's data field. +`x.grp.inv` message can also be sent to a group member via the member connection, to invite the member to another group. In this case, the message MUST only be sent when direct messages are allowed for the sender in the group of the member connection, and receiving clients MUST ignore it otherwise. + `x.grp.acpt` message is sent as part of group member connection handshake, only to the inviting user. It optionally includes the invited member's group profile. The inviting user replies with `x.grp.mem.info` that includes the inviting member's group profile. `x.grp.link.inv` message is sent as part of connection handshake to member joining via group link, and contains group profile and initial information about inviting and joining member. diff --git a/docs/rfcs/2026-10-10-invite-member-via-group.md b/docs/rfcs/2026-10-10-invite-member-via-group.md new file mode 100644 index 0000000000..865c8b08bc --- /dev/null +++ b/docs/rfcs/2026-10-10-invite-member-via-group.md @@ -0,0 +1,204 @@ +# Inviting group members to other groups + +## Problem + +A group admin can invite only a contact to a group. +To invite a member of group A to group B, the admin first creates a contact with this member: + +1. Create a member contact (`APICreateMemberContact`). +2. Send the contact invitation in group A (`x.grp.direct.inv`). +3. Wait until the contact connection is established. +4. Invite the contact to group B (`APIAddMember`, `x.grp.inv`). + +Steps 1-3 are possible only when direct messages are allowed in group A. +A direct chat is created with each invited member. + +## Design + +An admin of groups A and B invites a member of group A to group B. +The invitation is sent as `x.grp.inv` over the connection with this member in group A. +Only the invited member receives the invitation. +The invited member sees it in group A and joins group B in the same way as after an invitation from a contact. + +### Protocol + +`x.grp.inv` is accepted on group member connections, in addition to contact connections. + +``` +event x.grp.inv +params groupInvitation, unchanged +connection connection with the invited member in group A +forwarding excluded +group history excluded +``` + +Sender requirements: + +``` +group A preferences direct messages allowed for the user's role +role in group A author or above +role in group B admin or owner, and not lower than the invited role +group A group, not channel, different from group B +group B group, not channel +membership in A main profile +membership in B main profile +invited member current member with an active connection +invited member version 22 or later +``` + +The receiving client ignores the invitation when: + +``` +group A channel +group B channel +group A preferences direct messages not allowed for the sender's role +sender blocked +fromMember role below admin, or below the invited role +fromMember id equal to the invited member id +``` + +### Sending + +New command, next to `APIAddMember`: + +```haskell +APIAddMemberViaGroup + { groupId :: GroupId, + viaGroupId :: GroupId, + groupMemberId :: GroupMemberId, + memberRole :: GroupMemberRole + } +``` + +``` +groupId group B +viaGroupId group A +groupMemberId member of group A +memberRole role in group B +``` + +Syntax: `/_add # # ` + +For a member outside group A, `SEGroupMemberNotFound` is returned. + +New response, next to `CRSentGroupInvitation`: + +```haskell +CRSentGroupInvitationViaGroup + { user :: User, + groupInfo :: GroupInfo, + member :: GroupMember, + viaGroupInfo :: GroupInfo, + viaMember :: GroupMember + } +``` + +``` +groupInfo group B +member record of the invited member in group B +viaGroupInfo group A +viaMember member of group A +``` + +In group B, the record of the invited member is linked to the member of group A. +Until the member joins, the same invitation is re-sent on a repeated command. +After the member joins, `CEGroupDuplicateMember` is returned. +After the member leaves, is removed or is rejected, a new invitation is sent. + +The sent invitation is saved in group A as `CISndGroupInvitation`, visible to the sender only. +The name of the member in group A is stored in the item (`CIGroupInvitation.localDisplayName`), as the contact name in direct chats. + +### Receiving + +New event, next to `CEvtReceivedGroupInvitation`: + +```haskell +CEvtReceivedGroupInvitationViaGroup + { user :: User, + groupInfo :: GroupInfo, + viaGroupInfo :: GroupInfo, + viaMember :: GroupMember, + fromMemberRole :: GroupMemberRole, + memberRole :: GroupMemberRole + } +``` + +``` +groupInfo group B +viaGroupInfo group A +viaMember sender, member of group A +fromMemberRole sender role in group B +memberRole invited role in group B +``` + +``` +group B created with status "invited", or found by the invitation link +chat item CIRcvGroupInvitation in group A, main scope, from the sender +``` + +With automatic acceptance of group invitations enabled, the client joins group B and saves the item with status "accepted". + +With an incognito membership in group A, the same incognito profile is used in group B. + +In group B, copies of the group A profiles are stored for the sender and for the invited member. +Badge proofs bound to group A are dropped from the copies. +The copies are replaced by the profiles sent in the connection handshake (`x.grp.acpt`, `x.grp.mem.info`). + +### Joining + +The invited member joins group B with `APIJoinGroup`, or by tapping the invitation in group A. + +Status of the invitation item: + +``` +pending received +accepted joined +rejected group B deleted before joining +``` + +With disappearing messages on in group A, the item disappears after the status changes, as in direct chats. + +The connection with the host is created from the invitation link, independently of the inviter's connection. +So an invitation from a contact deleted before joining is accepted in the same way. +Before this change, joining failed with `SEContactNotFoundByMemberId`. + +### Clients + +``` +group A existing group invitation card ("Tap to join"), with a label +chat list group B, added on CEvtReceivedGroupInvitationViaGroup +sending API only +``` + +Labels, on group invitation cards in group chats only: + +``` +received card For you only +sent card For only name of the member in group A +``` + +### Bots API + +``` +APIAddMemberViaGroup new command +CRSentGroupInvitationViaGroup new response +CEvtReceivedGroupInvitationViaGroup new event +``` + +`APIAddMember`, `CRSentGroupInvitation` and `CEvtReceivedGroupInvitation`: unchanged. + +### Version + +``` +chat version 22 +``` + +Version 22 is shared with owner-signed relay invitations, in one constant `groupInvVersion`. +Version 22 is not in any release: v7.1.0-beta.6 uses version 21. + +## Out of scope + +- Role changes of members invited via a group before they join. +- "Open" action on accepted invitations. +- Invitations to members pending approval in group A. +- User interface for sending invitations. diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index cc01ae9472..fc1530f674 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -266,6 +266,23 @@ export namespace APIAddMember { } } +// Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +// Network usage: interactive. +export interface APIAddMemberViaGroup { + groupId: number // int64 + viaGroupId: number // int64 + groupMemberId: number // int64 + memberRole: T.GroupMemberRole +} + +export namespace APIAddMemberViaGroup { + export type Response = CR.SentGroupInvitationViaGroup | CR.ChatCmdError + + export function cmdString(self: APIAddMemberViaGroup): string { + return '/_add #' + self.groupId + ' #' + self.viaGroupId + ' ' + self.groupMemberId + ' ' + self.memberRole + } +} + // Join group. // Network usage: interactive. export interface APIJoinGroup { diff --git a/packages/simplex-chat-client/types/typescript/src/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts index 5b644ee41e..b1e8fc6057 100644 --- a/packages/simplex-chat-client/types/typescript/src/events.ts +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -17,6 +17,7 @@ export type ChatEvent = | CEvt.GroupChatItemsDeleted | CEvt.ChatItemsStatusesUpdated | CEvt.ReceivedGroupInvitation + | CEvt.ReceivedGroupInvitationViaGroup | CEvt.UserJoinedGroup | CEvt.GroupUpdated | CEvt.JoinedGroupMember @@ -73,6 +74,7 @@ export namespace CEvt { | "groupChatItemsDeleted" | "chatItemsStatusesUpdated" | "receivedGroupInvitation" + | "receivedGroupInvitationViaGroup" | "userJoinedGroup" | "groupUpdated" | "joinedGroupMember" @@ -210,6 +212,16 @@ export namespace CEvt { memberRole: T.GroupMemberRole } + export interface ReceivedGroupInvitationViaGroup extends Interface { + type: "receivedGroupInvitationViaGroup" + user: T.User + groupInfo: T.GroupInfo + viaGroupInfo: T.GroupInfo + viaMember: T.GroupMember + fromMemberRole: T.GroupMemberRole + memberRole: T.GroupMemberRole + } + export interface UserJoinedGroup extends Interface { type: "userJoinedGroup" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 647427f0dc..ef83e89e9d 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -51,6 +51,7 @@ export type ChatResponse = | CR.RemoteCtrlConnecting | CR.SentConfirmation | CR.SentGroupInvitation + | CR.SentGroupInvitationViaGroup | CR.SentInvitation | CR.SentInvitationToContact | CR.ServiceReplyAccepted @@ -117,6 +118,7 @@ export namespace CR { | "remoteCtrlConnecting" | "sentConfirmation" | "sentGroupInvitation" + | "sentGroupInvitationViaGroup" | "sentInvitation" | "sentInvitationToContact" | "serviceReplyAccepted" @@ -445,6 +447,15 @@ export namespace CR { member: T.GroupMember } + export interface SentGroupInvitationViaGroup extends Interface { + type: "sentGroupInvitationViaGroup" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + viaGroupInfo: T.GroupInfo + viaMember: T.GroupMember + } + export interface SentInvitation extends Interface { type: "sentInvitation" user: T.User diff --git a/packages/simplex-chat-client/typescript/src/client.ts b/packages/simplex-chat-client/typescript/src/client.ts index 10c4ee0a81..67e470e52d 100644 --- a/packages/simplex-chat-client/typescript/src/client.ts +++ b/packages/simplex-chat-client/typescript/src/client.ts @@ -261,6 +261,12 @@ export class ChatClient { throw new ChatCommandError("error adding member", r) } + async apiAddMemberViaGroup(groupId: number, viaGroupId: number, groupMemberId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})) + if (r.type === "sentGroupInvitationViaGroup") return r.member + throw new ChatCommandError("error adding member", r) + } + async apiJoinGroup(groupId: number): Promise { const r = await this.sendChatCmd(CC.APIJoinGroup.cmdString({groupId})) if (r.type === "userAcceptedGroupSent") return r.groupInfo diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 8d4e75b181..8e0c5a545d 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -564,6 +564,16 @@ export class ChatApi { throw new ChatCommandError("error adding member", r) } + /** + * Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. + * Network usage: interactive. + */ + async apiAddMemberViaGroup(groupId: number, viaGroupId: number, groupMemberId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})) + if (r.type === "sentGroupInvitationViaGroup") return r.member + throw new ChatCommandError("error adding member", r) + } + /** * Join group. * Network usage: interactive. diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py index 876230ef39..62dec42292 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -351,6 +351,23 @@ class ChatApi: return r["member"] raise ChatCommandError("error adding member", r) + async def api_add_member_via_group( + self, group_id: int, via_group_id: int, group_member_id: int, member_role: T.GroupMemberRole + ) -> T.GroupMember: + r = await self.send_chat_cmd( + CC.APIAddMemberViaGroup_cmd_string( + { + "groupId": group_id, + "viaGroupId": via_group_id, + "groupMemberId": group_member_id, + "memberRole": member_role, + } + ) + ) + if r["type"] == "sentGroupInvitationViaGroup": + return r["member"] + raise ChatCommandError("error adding member", r) + async def api_join_group(self, group_id: int) -> T.GroupInfo: r = await self.send_chat_cmd(CC.APIJoinGroup_cmd_string({"groupId": group_id})) if r["type"] == "userAcceptedGroupSent": diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index 365eff7e10..9dc3a05e8d 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -235,6 +235,21 @@ def APIAddMember_cmd_string(self: APIAddMember) -> str: APIAddMember_Response = CR.SentGroupInvitation | CR.ChatCmdError +# Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +# Network usage: interactive. +class APIAddMemberViaGroup(TypedDict): + groupId: int # int64 + viaGroupId: int # int64 + groupMemberId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APIAddMemberViaGroup_cmd_string(self: APIAddMemberViaGroup) -> str: + return '/_add #' + str(self['groupId']) + ' #' + str(self['viaGroupId']) + ' ' + str(self['groupMemberId']) + ' ' + str(self['memberRole']) + +APIAddMemberViaGroup_Response = CR.SentGroupInvitationViaGroup | CR.ChatCmdError + + # Join group. # Network usage: interactive. class APIJoinGroup(TypedDict): diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_events.py b/packages/simplex-chat-python/src/simplex_chat/types/_events.py index bd73637ef8..13f6d5cb6e 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_events.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_events.py @@ -84,6 +84,15 @@ class ReceivedGroupInvitation(TypedDict): fromMemberRole: "T.GroupMemberRole" memberRole: "T.GroupMemberRole" +class ReceivedGroupInvitationViaGroup(TypedDict): + type: Literal["receivedGroupInvitationViaGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + viaGroupInfo: "T.GroupInfo" + viaMember: "T.GroupMember" + fromMemberRole: "T.GroupMemberRole" + memberRole: "T.GroupMemberRole" + class UserJoinedGroup(TypedDict): type: Literal["userJoinedGroup"] user: "T.User" @@ -352,6 +361,7 @@ ChatEvent = ( | GroupChatItemsDeleted | ChatItemsStatusesUpdated | ReceivedGroupInvitation + | ReceivedGroupInvitationViaGroup | UserJoinedGroup | GroupUpdated | JoinedGroupMember @@ -394,7 +404,7 @@ ChatEvent = ( | ChatErrors ) -ChatEvent_Tag = Literal["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", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "remoteCtrlSessionCode", "remoteCtrlStopped", "messageError", "chatError", "chatErrors"] +ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "receivedGroupInvitationViaGroup", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "remoteCtrlSessionCode", "remoteCtrlStopped", "messageError", "chatError", "chatErrors"] class OnEventDecorator(Protocol): @@ -483,6 +493,12 @@ class OnEventDecorator(Protocol): Callable[["ReceivedGroupInvitation"], Awaitable[None]], ]: ... + @overload + def __call__(self, event: Literal["receivedGroupInvitationViaGroup"], /) -> Callable[ + [Callable[["ReceivedGroupInvitationViaGroup"], Awaitable[None]]], + Callable[["ReceivedGroupInvitationViaGroup"], Awaitable[None]], + ]: ... + @overload def __call__(self, event: Literal["userJoinedGroup"], /) -> Callable[ [Callable[["UserJoinedGroup"], Awaitable[None]]], diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py index e3885fc16a..07c7c04f49 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py @@ -264,6 +264,14 @@ class SentGroupInvitation(TypedDict): contact: "T.Contact" member: "T.GroupMember" +class SentGroupInvitationViaGroup(TypedDict): + type: Literal["sentGroupInvitationViaGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + viaGroupInfo: "T.GroupInfo" + viaMember: "T.GroupMember" + class SentInvitation(TypedDict): type: Literal["sentInvitation"] user: "T.User" @@ -402,6 +410,7 @@ ChatResponse = ( | RemoteCtrlConnecting | SentConfirmation | SentGroupInvitation + | SentGroupInvitationViaGroup | SentInvitation | SentInvitationToContact | ServiceReplyAccepted @@ -420,4 +429,4 @@ ChatResponse = ( | ApiChats ) -ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "remoteCtrlConnected", "remoteCtrlConnecting", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sentInvitationToContact", "serviceReplyAccepted", "sndFileCancelled", "startedConnectionToContact", "startedConnectionToGroup", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] +ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "remoteCtrlConnected", "remoteCtrlConnecting", "sentConfirmation", "sentGroupInvitation", "sentGroupInvitationViaGroup", "sentInvitation", "sentInvitationToContact", "serviceReplyAccepted", "sndFileCancelled", "startedConnectionToContact", "startedConnectionToGroup", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] diff --git a/plans/2026-10-10-invite-member-via-group.md b/plans/2026-10-10-invite-member-via-group.md new file mode 100644 index 0000000000..ecc9ab06af --- /dev/null +++ b/plans/2026-10-10-invite-member-via-group.md @@ -0,0 +1,612 @@ +# Inviting group members to other groups: implementation plan + +RFC: `docs/rfcs/2026-10-10-invite-member-via-group.md`. + +Branch: `ep/group-inv`. + +Line numbers: `d93fb626b` (`ep/group-inv` after merging `master`), before any change in this plan. + +## 1. Protocol version + +`src/Simplex/Chat/Protocol.hs`. + +Version 22 is shared with owner-signed relay invitations. + +Version history comment, after line 89: + +```haskell +-- 21 - command keywords with any characters (2026-09-27) +-- 22 - owner-signed relay invitations, group invitations via member connections (2026-10-10) +``` + +`currentChatVersion` (line 95): unchanged, `VersionChat 22`. + +`signedRelayInvVersion` (lines 146-147) renamed: + +```haskell +groupInvVersion :: VersionChat +groupInvVersion = VersionChat 22 +``` + +Uses: + +| File | Location | Use | +|---|---|---| +| `src/Simplex/Chat/Library/Subscriber.hs` | line 1657 | `xGrpRelayInv`, renamed reference | +| `src/Simplex/Chat/Library/Commands.hs` | section 5.1 | `APIAddMemberViaGroup` | + +Event types, tags, parsing and encoding: unchanged. + +## 2. Migration + +Files: + +- `src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs` +- `src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs` + +SQLite up: + +```sql +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +``` + +Postgres up: + +```sql +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +``` + +Down, both: + +```sql +DROP INDEX idx_group_members_invited_via_group_member_id; +ALTER TABLE group_members DROP COLUMN invited_via_group_member_id; +``` + +Registration: + +| File | Import | List entry | +|---|---|---| +| `src/Simplex/Chat/Store/SQLite/Migrations.hs` | after line 179 | after line 356 | +| `src/Simplex/Chat/Store/Postgres/Migrations.hs` | after line 56 | after line 110 | +| `simplex-chat.cabal` | after line 171 (Postgres) | after line 349 (SQLite) | + +Regenerated by tests: + +| File | Test | +|---|---| +| `src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql` | "Schema dump" | +| `src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt` | "Save query plans", last in the full test run | +| `src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql` | "Postgres schema dump", Postgres build; edited by hand | + +## 3. Command, response and event types + +`src/Simplex/Chat/Controller.hs`. + +`ChatCommand`, after line 467: + +```haskell + | APIAddMemberViaGroup {groupId :: GroupId, viaGroupId :: GroupId, groupMemberId :: GroupMemberId, memberRole :: GroupMemberRole} +``` + +`ChatResponse`, after line 879: + +```haskell + | CRSentGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, member :: GroupMember, viaGroupInfo :: GroupInfo, viaMember :: GroupMember} +``` + +`ChatEvent`, after line 1030: + +```haskell + | CEvtReceivedGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, viaGroupInfo :: GroupInfo, viaMember :: GroupMember, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} +``` + +`APIAddMember`, `CRSentGroupInvitation` and `CEvtReceivedGroupInvitation`: unchanged. + +## 4. Store + +`src/Simplex/Chat/Store/Groups.hs`. + +### 4.1. `createGroupInvitation` (line 458) + +```haskell +createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Either Contact GroupMember -> VersionRangeChat -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) +createGroupInvitation db cxt user@User {userId} inviter peerChatVRange GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do +``` + +Equation for `activeConn = Nothing` (line 459): removed. +Peer range: parameter, from the inviter connection. + +| Line | `Left ct` | `Right m` | +|---|---|---| +| 504 | `createContactMemberInv_`, unchanged | host row, see below | +| 505 | membership `invitedBy = IBContact contactId` | membership `invitedBy = IBUnknown` | + +Lines 504-505: + +```haskell + (groupMemberId, invitedBy) <- case inviter of + Left contact@Contact {contactId} -> do + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing ((\(MemberKey k) -> k) <$> fromMemberKey) currentTs hostVRange + pure (groupMemberId, IBContact contactId) + Right GroupMember {memberProfile} -> do + (hostName, memProfileId, hostProfile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing currentTs + let MemberIdRole {memberId, memberRole} = fromMember + host = + NewGroupMember + { memInfo = MemberInfo memberId memberRole (Just $ ChatVersionRange hostVRange) hostProfile fromMemberKey, + memCategory = GCHostMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUnknown, + memInvitedByGroupMemberId = Nothing, + localDisplayName = hostName, + memContactId = Nothing, + memProfileId + } + GroupMember {groupMemberId} <- createNewMember_ db user groupId host badgeVerified currentTs + pure (groupMemberId, IBUnknown) + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited invitedBy incognitoProfileId (Just $ fst memberKeys) currentTs (vr cxt) +``` + +Branch for an existing group (`getInvitationGroupId_`): unchanged. + +`createNewMember_` (line 2457): `GroupInfo` parameter → `GroupId`. +Calls in `createNewGroupMember` (line 2444) and `createIntroReMember` (line 2642): `(groupId' gInfo)`. + +### 4.2. `createNewMemberViaGroup` + +New function, after `createNewContactMember` (line 1379), before `createGroupRelayRecord` (line 1439). + +```haskell +createNewMemberViaGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupInfo -> GroupMember -> Connection -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createNewMemberViaGroup db gVar cxt user@User {userId} GroupInfo {groupId, membership} viaMember@GroupMember {memberProfile} Connection {connChatVersion, peerChatVRange} memberRole agentConnId connRequest subMode = + createWithRandomId' db gVar $ \memId -> runExceptT $ do + createdAt <- liftIO getCurrentTime + (localDisplayName, memProfileId, profile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing createdAt + let newMember = + NewGroupMember + { memInfo = MemberInfo (MemberId memId) memberRole (Just $ ChatVersionRange peerChatVRange) profile Nothing, + memCategory = GCInviteeMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUser, + memInvitedByGroupMemberId = Just $ groupMemberId' membership, + localDisplayName, + memContactId = Nothing, + memProfileId + } + member@GroupMember {groupMemberId} <- createNewMember_ db user groupId newMember badgeVerified createdAt + liftIO $ do + DB.execute + db + "UPDATE group_members SET sent_inv_queue_info = ?, invited_via_group_member_id = ? WHERE group_member_id = ?" + (connRequest, groupMemberId' viaMember, groupMemberId) + void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode + pure member +``` + +### 4.3. `getMemberInvitedVia` + +New function, after `getMemberInvitation` (line 2237). + +```haskell +getMemberInvitedVia :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> IO (Maybe GroupMember) +getMemberInvitedVia db cxt user@User {userId} GroupInfo {groupId} viaMember = do + currentTs <- getCurrentTime + maybeFirstRow (toContactMember currentTs cxt user) $ + DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.invited_via_group_member_id = ? AND m.member_status NOT IN (?,?,?)") + (userId, groupId, groupMemberId' viaMember, GSMemRejected, GSMemRemoved, GSMemLeft) +``` + +Excluded statuses: as `contactMember` (`Commands.hs` line 4008). + +## 5. Sending + +`src/Simplex/Chat/Library/Commands.hs`. + +### 5.1. `APIAddMemberViaGroup` handler + +After `APIAddMember` (line 2814), before `APIJoinGroup` (line 2844). + +```haskell + APIAddMemberViaGroup groupId viaGroupId gmId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do + (g@(GIK gInfo _), viaGInfo, m) <- withFastStore $ \db -> + (,,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupInfo db cxt user viaGroupId <*> getGroupMember db cxt user viaGroupId gmId + let GroupMember {localDisplayName = mName} = m + when (viaGroupId == groupId) $ throwChatError $ CEGroupDuplicateMember mName + when (useRelays' gInfo) $ throwCmdError "can't invite member to channel" + when (useRelays' viaGInfo) $ throwCmdError "can't invite member from channel" + assertUserGroupRole gInfo $ max GRAdmin memRole + assertUserGroupRole viaGInfo GRAuthor + unless (groupFeatureUserAllowed SGFDirectMessages viaGInfo) $ throwCmdError "direct messages not allowed" + when (incognitoMembership viaGInfo) $ throwChatError CEContactIncognitoCantInvite + when (incognitoMembership gInfo) $ throwChatError CEGroupIncognitoCantInvite + mConn <- case memberConn m of + Just conn | memberCurrent m -> pure conn + _ -> throwChatError CEGroupMemberNotActive + unless (m `supportsVersion` groupInvVersion) $ throwChatError CEPeerChatVRangeIncompatible + let sendInvitation = sendGrpInvitationViaGroup user viaGInfo m mConn g + withFastStore' (\db -> getMemberInvitedVia db cxt user gInfo m) >>= \case + Nothing -> do + gVar <- asks random + subMode <- chatReadVar subscriptionMode + (agentConnId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff True subMode + member <- withFastStore $ \db -> createNewMemberViaGroup db gVar cxt user gInfo m mConn memRole agentConnId cReq subMode + sendInvitation member cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member viaGInfo m + Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} + | memberStatus == GSMemInvited -> do + unless (mRole == memRole) $ withFastStore' $ \db -> updateGroupMemberRole db user member memRole + withFastStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case + Just cReq -> do + sendInvitation member {memberRole = memRole} cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member {memberRole = memRole} viaGInfo m + Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo mName + | otherwise -> throwChatError $ CEGroupDuplicateMember mName +``` + +### 5.2. `sendGrpInvitationViaGroup` + +New function, after `sendGrpInvitation` (line 4301). +`sendGrpInvitation`: unchanged. +`localDisplayName` in the item: name of the member in group A. + +```haskell + sendGrpInvitationViaGroup :: User -> GroupInfo -> GroupMember -> Connection -> GroupInfoKeys -> GroupMember -> ConnReqInvitation -> CM () + sendGrpInvitationViaGroup user viaGInfo GroupMember {localDisplayName} mConn (GIK gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} gks) GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership + groupInv = + GroupInvitation + { fromMember = MemberIdRole userMemberId userRole, + fromMemberKey = Just $ groupMemberKey gks, + invitedMember = MemberIdRole memberId memRole, + connRequest = cReq, + groupProfile, + business = businessChat, + groupLinkId = Nothing, + groupSize = Just currentMemCount + } + (msg, _, _) <- sendDirectMemberMessage mConn (XGrpInv groupInv) (groupId' viaGInfo) + let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole + ci <- saveSndChatItem user (CDGroupSnd viaGInfo Nothing) msg content + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat viaGInfo Nothing) ci] +``` + +### 5.3. Parser + +After line 6172: + +```haskell + "/_add #" *> (APIAddMemberViaGroup <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <*> memberRole), +``` + +## 6. Receiving + +`src/Simplex/Chat/Library/Subscriber.hs`. + +### 6.1. Group event dispatch + +After line 1123: + +```haskell + XGrpInv gInv -> Nothing <$ xGrpInv gInfo' m'' conn' gInv msg brokerTs +``` + +### 6.2. `processGroupInvitation` (line 2700) + +| Line | After | +|---|---| +| 2706 | `forM_ activeConn $ \conn@Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do` | +| 2711 | `(g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Left ct) peerChatVRange inv customUserProfileId memberKeys` | +| 2714-2726 | local `joinGroupAsync` and its comment: moved to 6.3 | +| 2734 | `joinGroupAsync g hostId conn connRequest (Just ct) True` | +| 2738 | `joinGroupAsync g hostId conn connRequest Nothing False` | + +### 6.3. `joinGroupAsync` + +Moved from `processGroupInvitation` to the `where` block of `processAgentMessageConn`, after `processGroupInvitation`. +Comment and body: unchanged. + +```haskell + joinGroupAsync :: GroupInfoKeys -> GroupMemberId -> Connection -> ConnReqInvitation -> Maybe Contact -> Bool -> CM () + joinGroupAsync (GIK gInfo@GroupInfo {groupId, membership} gks) hostId Connection {connId, connChatVersion, peerChatVRange} connRequest hostContact_ sameLink = do +``` + +### 6.4. `xGrpInv` + +New function, before `xGrpDirectInv` (line 3885). + +```haskell + xGrpInv :: GroupInfo -> GroupMember -> Connection -> GroupInvitation -> RcvMessage -> UTCTime -> CM () + xGrpInv viaGInfo m mConn@Connection {peerChatVRange} inv msg brokerTs + | useRelays' viaGInfo = messageError "x.grp.inv: not allowed in channels" + | isJust publicGroup = messageError "x.grp.inv: can't invite to channel" + | not (groupFeatureMemberAllowed SGFDirectMessages m viaGInfo) = messageError "x.grp.inv: direct messages not allowed" + | memberBlocked m = messageWarning "x.grp.inv: member is blocked (ignoring)" + | fromRole < GRAdmin || fromRole < memRole = messageError "x.grp.inv: inviting member role" + | fromMemId == memId = messageError "x.grp.inv: duplicate member ID" + | otherwise = do + memberKeys <- atomically . C.generateKeyPair =<< asks random + let incognitoProfileId = localProfileId <$> incognitoMembershipProfile viaGInfo + (g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Right m) peerChatVRange inv incognitoProfileId memberKeys + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) + let GroupMember {groupMemberId} = membership + createInvitationItem invStatus = do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv viaGInfo Nothing m) msg brokerTs content + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + groupMsgToView cInfo ci + if isTrue (autoAcceptGroupInvitations user) + then when (memberStatus membership == GSMemInvited) $ do + joinGroupAsync g hostId mConn connRequest Nothing False + createInvitationItem CIGISAccepted + else do + createInvitationItem CIGISPending + toView CEvtReceivedGroupInvitationViaGroup {user, groupInfo = gInfo, viaGroupInfo = viaGInfo, viaMember = m, fromMemberRole = fromRole, memberRole = memRole} + where + GroupInvitation {fromMember = MemberIdRole fromMemId fromRole, invitedMember = MemberIdRole memId memRole, connRequest, groupProfile = GroupProfile {publicGroup}} = inv +``` + +### 6.5. Profiles after joining + +The profile copies from 4.1 and 4.2 are replaced during the connection handshake, by existing code: + +| Side | Message | Handler | +|---|---|---| +| inviting client | `x.grp.acpt` with the profile of the invited member | `processMemberProfileUpdate` (line 805) | +| invited client | `x.grp.mem.info` with the profile of the inviting member | `processMemberProfileUpdate` (line 866) | + +## 7. Joining and invitation status + +`src/Simplex/Chat/Library/Commands.hs`. + +### 7.1. `APIJoinGroup` (line 2844) + +Lines 2846-2848: + +```haskell + (invitation, ct_) <- withFastStore $ \db -> do + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId + (inv,) <$> forM (memberContactId fromMember) (\_ -> getContactViaMember db cxt user fromMember) +``` + +Line 2850, `Contact {activeConn} = ct` replaced: + +```haskell + peerChatVRange_ = case ct_ of + Nothing -> Right $ memberChatVRange' fromMember + Just ct@Contact {activeConn} -> maybe (Left ct) (\Connection {peerChatVRange} -> Right peerChatVRange) activeConn +``` + +| Line | Before | After | +|---|---|---| +| 2851 | `case activeConn of` | `case peerChatVRange_ of` | +| 2852 | `Just Connection {peerChatVRange} -> do` | `Right peerChatVRange -> do` | +| 2875 | `Nothing -> throwChatError $ CEContactNotActive ct` | `Left ct -> throwChatError $ CEContactNotActive ct` | + +Lines 2853-2872: unchanged. + +Line 2873, missing invitation item ignored: + +```haskell + updateCIGroupInvitationStatus user g CIGISAccepted `catchAllErrors` \case + ChatErrorStore SEChatItemNotFoundByGroupId {} -> pure () + e -> eToView e +``` + +Invitation from a contact deleted before joining (`contact_id` set to NULL, `Store/Direct.hs` line 492): the member path is taken; master returned `SEContactNotFoundByMemberId`. +The invitation item is deleted with the contact (`Store/Direct.hs` line 483). + +### 7.2. `updateCIGroupInvitationStatus` (line 4776) + +Line 4778, item binding: `AChatItem _ _ cInfo ci@ChatItem {content, meta = meta@CIMeta {itemId}}`. + +New alternative, after line 4786. +Timer: as the direct alternative (lines 4783-4786), from the preferences of group A. + +```haskell + (GroupChat g _, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) + | status == CIGISPending -> do + let content' = CIRcvGroupInvitation (ciGroupInv {status = newStatus} :: CIGroupInvitation) memRole + timed_ <- sndGroupCITimed False g Nothing + ci' <- withFastStore' $ \db -> updateGroupChatItem db user (groupId' g) ci {meta = meta {itemTimed = timed_}} content' False False Nothing + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci') + forM_ (timed_ >>= timedDeleteAt') $ + startProximateTimedItemThread user (ChatRef CTGroup (groupId' g) Nothing, itemId) +``` + +## 8. Terminal + +### 8.1. `src/Simplex/Chat/View.hs` + +| Line | Change | +|---|---| +| after 210 | `CRSentGroupInvitationViaGroup u g _ viaG viaM -> ttyUser u ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyMember viaM <> " in " <> ttyGroup' viaG]` | +| 517 | `viewReceivedGroupInvitation g (ttyContact' c) r` | +| after 517 | `CEvtReceivedGroupInvitationViaGroup {user = u, groupInfo = g, viaGroupInfo = viaG, viaMember = m, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g (ttyMember m <> " in " <> ttyGroup' viaG) r` | +| 735 | `CISndGroupInvitation {} -> showSndItemProhibited to` removed | +| 746 | `CIRcvGroupInvitation {} \| isJust m_ -> showRcvItemProhibited from` removed | +| 1405 | `viewReceivedGroupInvitation` takes the inviter name as `StyledString` in place of `Contact` | + +Output for an invitation from a member: + +``` +#club: alice in #team invites you to join the group as member +use /j club to accept +``` + +### 8.2. `src/Simplex/Chat/Terminal/Output.hs` + +After line 200: + +```haskell + CEvtReceivedGroupInvitationViaGroup u g viaG m _ _ -> + when (groupNtf u viaG False) $ + sendNtf ("#" <> viewGroupName g <> " " <> viewMemberName m <> "> ", "invited you to join the group") +``` + +### 8.3. `src/Simplex/Chat/Terminal/Input.hs` + +After line 84: + +```haskell + CRSentGroupInvitationViaGroup u g _ _ _ -> whenCurrUser cc u $ setActiveGroup ct g +``` + +## 9. Directory service + +Unchanged: `APIAddMember` and `CEvtReceivedGroupInvitation` are unchanged. + +## 10. Bots API + +### 10.1. Docs + +`bots/src/API/Docs/Commands.hs`, after line 121: + +```haskell + ("APIAddMemberViaGroup", [], "Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group.", ["CRSentGroupInvitationViaGroup", "CRChatCmdError"], [TD "CEPeerChatVRangeIncompatible" "Member's client version is older than required"], Just UNInteractive, "/_add #" <> Param "groupId" <> " #" <> Param "viaGroupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), +``` + +`bots/src/API/Docs/Responses.hs`, after line 94: + +```haskell + ("CRSentGroupInvitationViaGroup", "Group invitation sent to member of another group"), +``` + +`bots/src/API/Docs/Events.hs`, after line 87: + +```haskell + ("CEvtReceivedGroupInvitationViaGroup", "Received group invitation from member of another group."), +``` + +Regenerated by `tests/APIDocs.hs`: + +- `bots/api/COMMANDS.md` +- `bots/api/EVENTS.md` +- `packages/simplex-chat-client/types/typescript/src/commands.ts` +- `packages/simplex-chat-client/types/typescript/src/responses.ts` +- `packages/simplex-chat-client/types/typescript/src/events.ts` +- `packages/simplex-chat-python/src/simplex_chat/types/_commands.py` +- `packages/simplex-chat-python/src/simplex_chat/types/_responses.py` +- `packages/simplex-chat-python/src/simplex_chat/types/_events.py` + +### 10.2. Wrappers + +New wrapper `apiAddMemberViaGroup`, after `apiAddMember`: + +| File | After line | Function | +|---|---|---| +| `packages/simplex-chat-nodejs/src/api.ts` | 565 | `apiAddMemberViaGroup(groupId, viaGroupId, groupMemberId, memberRole): Promise` | +| `packages/simplex-chat-python/src/simplex_chat/api.py` | 352 | `api_add_member_via_group(group_id, via_group_id, group_member_id, member_role)` | +| `packages/simplex-chat-client/typescript/src/client.ts` | 262 | `apiAddMemberViaGroup(groupId, viaGroupId, groupMemberId, memberRole)` | + +Command: `CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})`. +Response check: type `sentGroupInvitationViaGroup`, result `member`. +JSDoc in `api.ts`: the command description from 10.1, then "Network usage: interactive." + +## 11. Apps + +Sending: unchanged. + +### 11.1. Kotlin + +`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`: + +| Line | Change | +|---|---| +| after 6817 | `@Serializable @SerialName("receivedGroupInvitationViaGroup") class ReceivedGroupInvitationViaGroup(val user: UserRef, val groupInfo: GroupInfo, val viaGroupInfo: GroupInfo, val viaMember: GroupMember, val memberRole: GroupMemberRole): CR()` | +| after 7017 | `is ReceivedGroupInvitationViaGroup -> "receivedGroupInvitationViaGroup"` | +| after 7208 | `is ReceivedGroupInvitationViaGroup -> withUser(user, "groupInfo: $groupInfo\nviaGroupInfo: $viaGroupInfo\nviaMember: $viaMember\nmemberRole: $memberRole")` | + +Handler, after line 3130: + +```kotlin + is CR.ReceivedGroupInvitationViaGroup -> { + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + } + } + } +``` + +Card label: + +| File | Line | Change | +|---|---|---| +| `apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml` | after 1769 | `For you only` | +| `apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml` | after 1769 | `For %1$s only` | +| `.../views/chat/item/CIGroupInvitationView.kt` | after 31 | parameter `groupChat: Boolean = false,` | +| `.../views/chat/item/CIGroupInvitationView.kt` | after 113 | `if (groupChat) {` / `Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor)` / `}` | +| `.../views/chat/item/ChatItemView.kt` | 723, 727 | argument `groupChat = cInfo is ChatInfo.Group` | + +### 11.2. Swift + +`apps/ios/Shared/Model/AppAPITypes.swift`: + +| Line | Change | +|---|---| +| after 1193 | `case receivedGroupInvitationViaGroup(user: UserRef, groupInfo: GroupInfo, viaGroupInfo: GroupInfo, viaMember: GroupMember, memberRole: GroupMemberRole)` | +| after 1274 | `case .receivedGroupInvitationViaGroup: "receivedGroupInvitationViaGroup"` | +| after 1358 | `case let .receivedGroupInvitationViaGroup(u, groupInfo, viaGroupInfo, viaMember, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nviaGroupInfo: \(viaGroupInfo)\nviaMember: \(viaMember)\nmemberRole: \(memberRole)")` | + +Handler, `apps/ios/Shared/Model/SimpleXAPI.swift`, line 2708, second pattern in the `receivedGroupInvitation` case: + +```swift + case let .receivedGroupInvitation(user, groupInfo, _, _), + let .receivedGroupInvitationViaGroup(user, groupInfo, _, _, _): +``` + +Card label, `apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`, after line 41: + +```swift + if chat.chatInfo.groupInfo != nil { + Text(chatItem.chatDir.sent ? String.localizedStringWithFormat(NSLocalizedString("For %@ only", comment: ""), groupInvitation.localDisplayName) : NSLocalizedString("For you only", comment: "")) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .overlay(DetermineWidth()) + } + +``` + +## 12. Protocol docs + +`docs/protocol/simplex-chat.md`, new paragraph after the `x.grp.inv` paragraph (line 233): + +``` +`x.grp.inv` message can also be sent to a group member via the member connection, to invite the member to another group. In this case, the message MUST only be sent when direct messages are allowed for the sender in the group of the member connection, and receiving clients MUST ignore it otherwise. +``` + +## 13. Tests + +`tests/ChatTests/Groups.hs`, new `describe "invite member via group"`, command `/_add # # member`: + +| Test | Setup | Expected | +|---|---|---| +| `testInviteMemberViaGroup` | alice owner of #team and #club; bob and cath in #team | invitation cards in #team; `cath ` and `/a`: unchanged; they cover the `APIAddMember` parser next to the new `/_add` alternative. + +`tests/APIDocs.hs`: run to regenerate the files in 10.1. + +## 14. Version + +Version 22 is not in any release: v7.1.0-beta.6 uses version 21. +If a release with version 22 ships before this feature, a separate constant with version 23 is added for this feature, and `currentChatVersion` is raised to 23. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index cb0e3387ed..0348469060 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -169,6 +169,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings + Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group else exposed-modules: Simplex.Chat.Archive @@ -347,6 +348,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings + Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index be3025bd25..2d553bdc9b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -465,6 +465,7 @@ data ChatCommand | APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString} | APIGetConnNtfMessages (NonEmpty ConnMsgReq) | APIAddMember {groupId :: GroupId, contactId :: ContactId, memberRole :: GroupMemberRole} + | APIAddMemberViaGroup {groupId :: GroupId, viaGroupId :: GroupId, groupMemberId :: GroupMemberId, memberRole :: GroupMemberRole} | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} | APIAcceptMember {groupId :: GroupId, groupMemberId :: GroupMemberId, memberRole :: GroupMemberRole} | APIDeleteMemberSupportChat GroupId GroupMemberId @@ -877,6 +878,7 @@ data ChatResponse | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} + | CRSentGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, member :: GroupMember, viaGroupInfo :: GroupInfo, viaMember :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRFileTransferStatusXFTP User AChatItem | CRUserProfile {user :: User, profile :: Profile} @@ -1028,6 +1030,7 @@ data ChatEvent | CEvtHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} + | CEvtReceivedGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, viaGroupInfo :: GroupInfo, viaMember :: GroupMember, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | CEvtUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CEvtGroupLinkDataUpdated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay], relaysChanged :: Bool} | CEvtGroupRelayUpdated {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRelay :: GroupRelay} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 1c0510c31d..c37c45ec90 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2839,15 +2839,51 @@ processChatCommand cxt nm = \case pure $ CRSentGroupInvitation user gInfo contact member {memberRole = memRole} Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName + APIAddMemberViaGroup groupId viaGroupId gmId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do + (g@(GIK gInfo _), viaGInfo, m) <- withFastStore $ \db -> + (,,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupInfo db cxt user viaGroupId <*> getGroupMember db cxt user viaGroupId gmId + let GroupMember {localDisplayName = mName} = m + when (viaGroupId == groupId) $ throwChatError $ CEGroupDuplicateMember mName + when (useRelays' gInfo) $ throwCmdError "can't invite member to channel" + when (useRelays' viaGInfo) $ throwCmdError "can't invite member from channel" + assertUserGroupRole gInfo $ max GRAdmin memRole + assertUserGroupRole viaGInfo GRAuthor + unless (groupFeatureUserAllowed SGFDirectMessages viaGInfo) $ throwCmdError "direct messages not allowed" + when (incognitoMembership viaGInfo) $ throwChatError CEContactIncognitoCantInvite + when (incognitoMembership gInfo) $ throwChatError CEGroupIncognitoCantInvite + mConn <- case memberConn m of + Just conn | memberCurrent m -> pure conn + _ -> throwChatError CEGroupMemberNotActive + unless (m `supportsVersion` groupInvVersion) $ throwChatError CEPeerChatVRangeIncompatible + let sendInvitation = sendGrpInvitationViaGroup user viaGInfo m mConn g + withFastStore' (\db -> getMemberInvitedVia db cxt user gInfo m) >>= \case + Nothing -> do + gVar <- asks random + subMode <- chatReadVar subscriptionMode + (agentConnId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff True subMode + member <- withFastStore $ \db -> createNewMemberViaGroup db gVar cxt user gInfo m mConn memRole agentConnId cReq subMode + sendInvitation member cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member viaGInfo m + Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} + | memberStatus == GSMemInvited -> do + unless (mRole == memRole) $ withFastStore' $ \db -> updateGroupMemberRole db user member memRole + withFastStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case + Just cReq -> do + sendInvitation member {memberRole = memRole} cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member {memberRole = memRole} viaGInfo m + Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo mName + | otherwise -> throwChatError $ CEGroupDuplicateMember mName APIJoinGroup groupId enableNtfs -> withUser $ \user@User {userId} -> do withGroupLock "joinGroup" groupId $ do - (invitation, ct) <- withFastStore $ \db -> do + (invitation, ct_) <- withFastStore $ \db -> do inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId - (inv,) <$> getContactViaMember db cxt user fromMember + (inv,) <$> forM (memberContactId fromMember) (\_ -> getContactViaMember db cxt user fromMember) let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}, groupKeys = gks} = invitation - Contact {activeConn} = ct - case activeConn of - Just Connection {peerChatVRange} -> do + peerChatVRange_ = case ct_ of + Nothing -> Right $ memberChatVRange' fromMember + Just ct@Contact {activeConn} -> maybe (Left ct) (\Connection {peerChatVRange} -> Right peerChatVRange) activeConn + case peerChatVRange_ of + Right peerChatVRange -> do subMode <- chatReadVar subscriptionMode dm <- encodeXGrpAcpt user (GIK g gks) peerChatVRange agentConnId <- case memberConn fromMember of @@ -2868,9 +2904,11 @@ processChatCommand cxt nm = \case updateGroupMemberStatus db userId fromMember GSMemInvited updateGroupMemberStatus db userId membership GSMemInvited throwError e - updateCIGroupInvitationStatus user g CIGISAccepted `catchAllErrors` eToView + updateCIGroupInvitationStatus user g CIGISAccepted `catchAllErrors` \case + ChatErrorStore SEChatItemNotFoundByGroupId {} -> pure () + e -> eToView e pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing - Nothing -> throwChatError $ CEContactNotActive ct + Left ct -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do (g@(GIK gInfo _), m) <- withFastStore $ \db -> (,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupMemberById db cxt user gmId -- same rule as role change (moderators grant up to member); pending member's role is a stand-in, so treat it as member @@ -4318,6 +4356,25 @@ processChatCommand cxt nm = \case toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId' ci) + sendGrpInvitationViaGroup :: User -> GroupInfo -> GroupMember -> Connection -> GroupInfoKeys -> GroupMember -> ConnReqInvitation -> CM () + sendGrpInvitationViaGroup user viaGInfo GroupMember {localDisplayName} mConn (GIK gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} gks) GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership + groupInv = + GroupInvitation + { fromMember = MemberIdRole userMemberId userRole, + fromMemberKey = Just $ groupMemberKey gks, + invitedMember = MemberIdRole memberId memRole, + connRequest = cReq, + groupProfile, + business = businessChat, + groupLinkId = Nothing, + groupSize = Just currentMemCount + } + (msg, _, _) <- sendDirectMemberMessage mConn (XGrpInv groupInv) (groupId' viaGInfo) + let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole + ci <- saveSndChatItem user (CDGroupSnd viaGInfo Nothing) msg content + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat viaGInfo Nothing) ci] addRelays :: User -> GroupInfoKeys -> ShortLinkContact -> [UserChatRelay] -> CM [(UserChatRelay, Either ChatError GroupRelay)] addRelays user g@(GIK gInfo@GroupInfo {membership} gks) groupSLink relays = mapConcurrently addRelay relays @@ -4773,7 +4830,7 @@ processChatCommand cxt nm = \case shortenShortLink' =<< withAgent (\a -> setConnShortLink a nm (aConnId' conn) SCMInvitation userLinkData Nothing False Nothing) updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM () updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do - AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db cxt user groupId + AChatItem _ _ cInfo ci@ChatItem {content, meta = meta@CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db cxt user groupId case (cInfo, content) of (DirectChat ct@Contact {contactId}, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) | status == CIGISPending -> do @@ -4782,6 +4839,14 @@ processChatCommand cxt nm = \case updateDirectChatItemView user ct itemId aciContent False False timed_ Nothing forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, itemId) + (GroupChat g _, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) + | status == CIGISPending -> do + let content' = CIRcvGroupInvitation (ciGroupInv {status = newStatus} :: CIGroupInvitation) memRole + timed_ <- sndGroupCITimed False g Nothing + ci' <- withFastStore' $ \db -> updateGroupChatItem db user (groupId' g) ci {meta = meta {itemTimed = timed_}} content' False False Nothing + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci') + forM_ (timed_ >>= timedDeleteAt') $ + startProximateTimedItemThread user (ChatRef CTGroup (groupId' g) Nothing, itemId) _ -> pure () -- prohibited assertAllowedContent :: MsgContent -> CM () assertAllowedContent = \case @@ -6168,6 +6233,7 @@ chatCommandP = "/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP), "/_ntf conn messages " *> (APIGetConnNtfMessages <$> connMsgsP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), + "/_add #" *> (APIAddMemberViaGroup <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI "/_accept member #" *> (APIAcceptMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_delete member chat #" *> (APIDeleteMemberSupportChat <$> A.decimal <* A.space <*> A.decimal), diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a07a8c754b..a1e748831c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1121,6 +1121,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' m'' gr verifiedMsg sharedMsgId_ brokerTs XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck (GIK gInfo' gks) m'' ackVer ackErr XGrpRosterRequest reqVer -> Nothing <$ xGrpRosterRequest (GIK gInfo' gks) m'' reqVer + XGrpInv gInv -> Nothing <$ xGrpInv gInfo' m'' conn' gInv msg brokerTs -- TODO [knocking] why don't we forward these messages? XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward (GIK gInfo' gks) Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs @@ -1654,7 +1655,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason xGrpRelayInv :: InvitationId -> VersionRangeChat -> Maybe SignedMsg -> GroupRelayInvitation -> CM () xGrpRelayInv invId chatVRange signedMsg_ groupRelayInv@GroupRelayInvitation {groupLink} - | maxVersion chatVRange >= signedRelayInvVersion && isNothing presHeader_ = + | maxVersion chatVRange >= groupInvVersion && isNothing presHeader_ = messageError "x.grp.relay.inv: invitation is not signed by the owner" | otherwise = do rejected <- withStore' $ \db -> isRelayGroupRejected db user groupLink @@ -2703,27 +2704,14 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | otherwise = do let Contact {localDisplayName = c, activeConn} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv - forM_ activeConn $ \Connection {connId, connChatVersion, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do + forM_ activeConn $ \conn@Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile memberKeys <- atomically . C.generateKeyPair =<< asks random - (GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} gks, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId memberKeys + (g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Left ct) peerChatVRange inv customUserProfileId memberKeys void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let GroupMember {groupMemberId} = membership - -- hostContact is only reported for group links, where the client replaces - -- the transient host connection view with the group and removes its chat - joinGroupAsync hostContact_ sameLink = do - subMode <- chatReadVar subscriptionMode - dm <- encodeXGrpAcpt user (GIK gInfo gks) peerChatVRange - (connIds@(cmdId, acId), _) <- prepareAgentJoin user True connRequest - withStore' $ \db -> do - when sameLink $ setViaGroupLinkUri db groupId connId - createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode - updateGroupMemberStatusById db userId hostId GSMemAccepted - updateGroupMemberStatus db userId membership GSMemAccepted - joinAgentConnectionAsync cmdId False acId True connRequest dm subMode - toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} hostContact_ createInvitationItem invStatus = do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content @@ -2731,11 +2719,11 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] if | sameGroupLinkId groupLinkId groupLinkId' -> - joinGroupAsync (Just ct) True + joinGroupAsync g hostId conn connRequest (Just ct) True | isTrue (autoAcceptGroupInvitations user) -> -- a resent invitation returns the existing group, so only join while still invited when (memberStatus membership == GSMemInvited) $ do - joinGroupAsync Nothing False + joinGroupAsync g hostId conn connRequest Nothing False createInvitationItem CIGISAccepted | otherwise -> do createInvitationItem CIGISPending @@ -2747,6 +2735,21 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag sameGroupLinkId (Just gli) (Just gli') = gli == gli' sameGroupLinkId _ _ = False + -- hostContact is only reported for group links, where the client replaces + -- the transient host connection view with the group and removes its chat + joinGroupAsync :: GroupInfoKeys -> GroupMemberId -> Connection -> ConnReqInvitation -> Maybe Contact -> Bool -> CM () + joinGroupAsync (GIK gInfo@GroupInfo {groupId, membership} gks) hostId Connection {connId, connChatVersion, peerChatVRange} connRequest hostContact_ sameLink = do + subMode <- chatReadVar subscriptionMode + dm <- encodeXGrpAcpt user (GIK gInfo gks) peerChatVRange + (connIds@(cmdId, acId), _) <- prepareAgentJoin user True connRequest + withStore' $ \db -> do + when sameLink $ setViaGroupLinkUri db groupId connId + createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode + updateGroupMemberStatusById db userId hostId GSMemAccepted + updateGroupMemberStatus db userId membership GSMemAccepted + joinAgentConnectionAsync cmdId False acId True connRequest dm subMode + toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} hostContact_ + checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> CM () checkIntegrityCreateItem cd MsgMeta {integrity, broker = (_, brokerTs)} = case integrity of MsgOk -> pure () @@ -3882,6 +3885,35 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag unless (isUserGrpFwdRelay gInfo) $ connectToRelayAsync user gInfo rl pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + xGrpInv :: GroupInfo -> GroupMember -> Connection -> GroupInvitation -> RcvMessage -> UTCTime -> CM () + xGrpInv viaGInfo m mConn@Connection {peerChatVRange} inv msg brokerTs + | useRelays' viaGInfo = messageError "x.grp.inv: not allowed in channels" + | isJust publicGroup = messageError "x.grp.inv: can't invite to channel" + | not (groupFeatureMemberAllowed SGFDirectMessages m viaGInfo) = messageError "x.grp.inv: direct messages not allowed" + | memberBlocked m = messageWarning "x.grp.inv: member is blocked (ignoring)" + | fromRole < GRAdmin || fromRole < memRole = messageError "x.grp.inv: inviting member role" + | fromMemId == memId = messageError "x.grp.inv: duplicate member ID" + | otherwise = do + memberKeys <- atomically . C.generateKeyPair =<< asks random + let incognitoProfileId = localProfileId <$> incognitoMembershipProfile viaGInfo + (g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Right m) peerChatVRange inv incognitoProfileId memberKeys + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) + let GroupMember {groupMemberId} = membership + createInvitationItem invStatus = do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv viaGInfo Nothing m) msg brokerTs content + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + groupMsgToView cInfo ci + if isTrue (autoAcceptGroupInvitations user) + then when (memberStatus membership == GSMemInvited) $ do + joinGroupAsync g hostId mConn connRequest Nothing False + createInvitationItem CIGISAccepted + else do + createInvitationItem CIGISPending + toView CEvtReceivedGroupInvitationViaGroup {user, groupInfo = gInfo, viaGroupInfo = viaGInfo, viaMember = m, fromMemberRole = fromRole, memberRole = memRole} + where + GroupInvitation {fromMember = MemberIdRole fromMemId fromRole, invitedMember = MemberIdRole memId memRole, connRequest, groupProfile = GroupProfile {publicGroup}} = inv + xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> UTCTime -> CM () xGrpDirectInv g@GroupInfo {groupId, groupProfile = gp} m mConn@Connection {connId = mConnId} connReq mContent_ msg brokerTs | not (groupFeatureMemberAllowed SGFDirectMessages m g) = messageError "x.grp.direct.inv: direct messages not allowed" diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index aad0254b52..1266104e0e 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -87,6 +87,8 @@ import Simplex.Messaging.Version hiding (version) -- 18 - relay web capabilities (2026-05-31) -- 19 - group roster (2026-06-18) -- 20 - p2p group member keys for signing (2026-07-26) +-- 21 - command keywords with any characters (2026-09-27) +-- 22 - owner-signed relay invitations, group invitations via member connections (2026-10-10) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. @@ -143,8 +145,8 @@ groupMemberKeyVersion = VersionChat 20 anyTextCommandsVersion :: VersionChat anyTextCommandsVersion = VersionChat 21 -signedRelayInvVersion :: VersionChat -signedRelayInvVersion = VersionChat 22 +groupInvVersion :: VersionChat +groupInvVersion = VersionChat 22 data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 6f9ca5a068..e9d99fbe89 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -86,6 +86,7 @@ module Simplex.Chat.Store.Groups getContactGroupPreferences, getGroupInvitation, createNewContactMember, + createNewMemberViaGroup, createGroupRelayRecord, getGroupRelayById, getGroupRelayByGMId, @@ -140,6 +141,7 @@ module Simplex.Chat.Store.Groups createBusinessRequestGroup, getContactViaMember, getMemberInvitation, + getMemberInvitedVia, createMemberConnection, createMemberConnectionAsync, updatePreparedRelayedGroup, @@ -455,9 +457,8 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile memberId } -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) -createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ = throwError $ SEContactNotReady localDisplayName -createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do +createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Either Contact GroupMember -> VersionRangeChat -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) +createGroupInvitation db cxt user@User {userId} inviter peerChatVRange GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do liftIO getInvitationGroupId_ >>= \case Nothing -> createGroupInvitation_ Just gId -> do @@ -501,8 +502,28 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs, snd memberKeys) :. businessChatInfoRow business) insertedRowId db let hostVRange = adjustedMemberVRange (vr cxt) peerChatVRange - GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing ((\(MemberKey k) -> k) <$> fromMemberKey) currentTs hostVRange - membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId (Just $ fst memberKeys) currentTs (vr cxt) + (groupMemberId, invitedBy) <- case inviter of + Left contact@Contact {contactId} -> do + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing ((\(MemberKey k) -> k) <$> fromMemberKey) currentTs hostVRange + pure (groupMemberId, IBContact contactId) + Right GroupMember {memberProfile} -> do + (hostName, memProfileId, hostProfile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing currentTs + let MemberIdRole {memberId, memberRole} = fromMember + host = + NewGroupMember + { memInfo = MemberInfo memberId memberRole (Just $ ChatVersionRange hostVRange) hostProfile fromMemberKey, + memCategory = GCHostMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUnknown, + memInvitedByGroupMemberId = Nothing, + localDisplayName = hostName, + memContactId = Nothing, + memProfileId + } + GroupMember {groupMemberId} <- createNewMember_ db user groupId host badgeVerified currentTs + pure (groupMemberId, IBUnknown) + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited invitedBy incognitoProfileId (Just $ fst memberKeys) currentTs (vr cxt) let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure ( GroupInfo @@ -1436,6 +1457,32 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, ) pure indexInGroup +createNewMemberViaGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupInfo -> GroupMember -> Connection -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createNewMemberViaGroup db gVar cxt user@User {userId} GroupInfo {groupId, membership} viaMember@GroupMember {memberProfile} Connection {connChatVersion, peerChatVRange} memberRole agentConnId connRequest subMode = + createWithRandomId' db gVar $ \memId -> runExceptT $ do + createdAt <- liftIO getCurrentTime + (localDisplayName, memProfileId, profile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing createdAt + let newMember = + NewGroupMember + { memInfo = MemberInfo (MemberId memId) memberRole (Just $ ChatVersionRange peerChatVRange) profile Nothing, + memCategory = GCInviteeMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUser, + memInvitedByGroupMemberId = Just $ groupMemberId' membership, + localDisplayName, + memContactId = Nothing, + memProfileId + } + member@GroupMember {groupMemberId} <- createNewMember_ db user groupId newMember badgeVerified createdAt + liftIO $ do + DB.execute + db + "UPDATE group_members SET sent_inv_queue_info = ?, invited_via_group_member_id = ? WHERE group_member_id = ?" + (connRequest, groupMemberId' viaMember, groupMemberId) + void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode + pure member + createGroupRelayRecord :: DB.Connection -> GroupInfo -> GroupMember -> UserChatRelay -> ExceptT StoreError IO GroupRelay createGroupRelayRecord db GroupInfo {groupId} GroupMember {groupMemberId} UserChatRelay {chatRelayId} = do currentTs <- liftIO getCurrentTime @@ -2239,6 +2286,15 @@ getMemberInvitation db User {userId} groupMemberId = fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?" (groupMemberId, userId) +getMemberInvitedVia :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> IO (Maybe GroupMember) +getMemberInvitedVia db cxt user@User {userId} GroupInfo {groupId} viaMember = do + currentTs <- getCurrentTime + maybeFirstRow (toContactMember currentTs cxt user) $ + DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.invited_via_group_member_id = ? AND m.member_status NOT IN (?,?,?)") + (userId, groupId, groupMemberId' viaMember, GSMemRejected, GSMemRemoved, GSMemLeft) + createMemberConnection :: DB.Connection -> UserId -> GroupMember -> ConnId -> VersionChat -> VersionRangeChat -> SubscriptionMode -> IO Connection createMemberConnection db userId GroupMember {groupMemberId} agentConnId chatV peerChatVRange subMode = do currentTs <- getCurrentTime @@ -2441,7 +2497,7 @@ createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profil memContactId = Nothing, memProfileId } - createNewMember_ db user gInfo newMember badgeVerified currentTs + createNewMember_ db user (groupId' gInfo) newMember badgeVerified currentTs createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> Maybe ProofPresHeader -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Profile, Maybe Bool) createNewMemberProfile_ db cxt User {userId} p@Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences} presHeader_ createdAt = @@ -2454,11 +2510,11 @@ createNewMemberProfile_ db cxt User {userId} p@Profile {displayName, fullName, s profileId <- insertedRowId db pure $ Right (ldn, profileId, p', badgeVerified) -createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> Maybe Bool -> UTCTime -> ExceptT StoreError IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupId -> NewGroupMember -> Maybe Bool -> UTCTime -> ExceptT StoreError IO GroupMember createNewMember_ db User {userId, userContactId} - GroupInfo {groupId} + groupId NewGroupMember { memInfo = MemberInfo memberId memberRole memChatVRange memberProfile@Profile {badge} memKey, memCategory = memberCategory, @@ -2639,7 +2695,7 @@ createIntroReMember (localDisplayName, memProfileId, memberProfile', badgeVerified) <- createNewMemberProfile_ db cxt user memberProfile presHeader_ currentTs let memRestriction = restriction <$> memRestrictions_ newMember = NewGroupMember {memInfo = (memInfo :: MemberInfo) {profile = memberProfile'}, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} - createNewMember_ db user gInfo newMember badgeVerified currentTs + createNewMember_ db user (groupId' gInfo) newMember badgeVerified currentTs createIntroReMemberConn :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> MemberInfo -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMemberConn diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 36f594560a..dc2de08739 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -54,6 +54,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json import Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings +import Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -107,7 +108,8 @@ schemaMigrations = ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), - ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings) + ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs new file mode 100644 index 0000000000..a491dff05e --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20261010_invite_member_via_group :: Text +m20261010_invite_member_via_group = + [r| +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +|] + +down_m20261010_invite_member_via_group :: Text +down_m20261010_invite_member_via_group = + [r| +DROP INDEX idx_group_members_invited_via_group_member_id; +ALTER TABLE group_members DROP COLUMN invited_via_group_member_id; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 9c7ab5aa32..835181199c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1022,7 +1022,8 @@ CREATE TABLE test_chat_schema.group_members ( removed_at timestamp with time zone, roster_served_version bigint, member_security_code text, - member_security_code_verified_at timestamp with time zone + member_security_code_verified_at timestamp with time zone, + invited_via_group_member_id bigint ); @@ -2709,6 +2710,10 @@ CREATE INDEX idx_group_members_invited_by_group_member_id ON test_chat_schema.gr +CREATE INDEX idx_group_members_invited_via_group_member_id ON test_chat_schema.group_members USING btree (invited_via_group_member_id); + + + CREATE INDEX idx_group_members_member_profile_id ON test_chat_schema.group_members USING btree (member_profile_id); @@ -3465,6 +3470,11 @@ ALTER TABLE ONLY test_chat_schema.group_members +ALTER TABLE ONLY test_chat_schema.group_members + ADD CONSTRAINT group_members_invited_via_group_member_id_fkey FOREIGN KEY (invited_via_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE SET NULL; + + + ALTER TABLE ONLY test_chat_schema.group_members ADD CONSTRAINT group_members_member_profile_id_fkey FOREIGN KEY (member_profile_id) REFERENCES test_chat_schema.contact_profiles(contact_profile_id) ON DELETE SET NULL; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index f16351f6c2..3e6d6694c6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -177,6 +177,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json import Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings +import Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -353,7 +354,8 @@ schemaMigrations = ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), - ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings) + ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs new file mode 100644 index 0000000000..0212117800 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20261010_invite_member_via_group :: Query +m20261010_invite_member_via_group = + [sql| +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +|] + +down_m20261010_invite_member_via_group :: Query +down_m20261010_invite_member_via_group = + [sql| +DROP INDEX idx_group_members_invited_via_group_member_id; +ALTER TABLE group_members DROP COLUMN invited_via_group_member_id; +|] 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 535ca23fca..de3088123c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -55,6 +55,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -110,6 +111,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -322,6 +324,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -359,6 +362,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -396,6 +400,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -621,6 +626,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -657,6 +663,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -694,6 +701,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -731,6 +739,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -1304,6 +1313,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -1342,6 +1352,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -1997,6 +2008,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -2034,6 +2046,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -6235,6 +6248,29 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, p.preferences_json, + p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified, + 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, m.member_pub_key, m.relay_link, m.member_security_code, m.member_security_code_verified_at, + 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, + bp.badge_proof, bp.badge_pres_header, bp.badge_key_idx, bp.badge_type, bp.badge_expiry, bp.badge_extra + 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 + LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND m.invited_via_group_member_id = ? AND m.member_status NOT IN (?,?,?) +Plan: +SEARCH m USING INDEX idx_group_members_invited_via_group_member_id (invited_via_group_member_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +SEARCH bp USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN + Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, @@ -7006,6 +7042,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -7038,6 +7075,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -7070,6 +7108,7 @@ SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_membe 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_via_group_member_id (invited_via_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=?) @@ -7603,7 +7642,7 @@ SCAN files Query: SELECT count(1) FROM group_members Plan: -SCAN group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id +SCAN group_members USING COVERING INDEX idx_group_members_invited_via_group_member_id Query: SELECT count(1) FROM pending_group_messages Plan: @@ -7703,6 +7742,10 @@ Query: SELECT issue_failed_since, issue_error FROM badge_purchases Plan: SCAN badge_purchases +Query: SELECT item_content FROM chat_items WHERE group_id = 1 ORDER BY chat_item_id DESC LIMIT 1 +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) + Query: SELECT last_insert_rowid() Plan: SCAN CONSTANT ROW @@ -8183,6 +8226,10 @@ Query: UPDATE group_members SET roster_served_version = ?, updated_at = ? WHERE Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_members SET sent_inv_queue_info = ?, invited_via_group_member_id = ? WHERE group_member_id = ? +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=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 95a837f5e1..d60a41047f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -260,6 +260,7 @@ CREATE TABLE group_members( roster_served_version INTEGER, member_security_code TEXT, member_security_code_verified_at TEXT, + invited_via_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -1557,6 +1558,9 @@ CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions( CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases( badge_code_redemption_id ); +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members( + invited_via_group_member_id +); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 3f1c434433..d6681518a9 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -82,6 +82,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c CRGroupDeletedUser u g _ _ -> whenCurrUser cc u $ unsetActiveGroup ct g CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g + CRSentGroupInvitationViaGroup u g _ _ _ -> whenCurrUser cc u $ setActiveGroup ct g CRCmdOk _ -> case cmd of Right APIDeleteUser {} -> setActive ct "" _ -> pure () diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 63a3d5cc70..7264a65bbc 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -198,6 +198,9 @@ chatEventNotification t@ChatTerminal {sendNotification} cc = \case CEvtReceivedGroupInvitation u g ct _ _ -> when (contactNtf u ct False) $ sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") + CEvtReceivedGroupInvitationViaGroup u g viaG m _ _ -> + when (groupNtf u viaG False) $ + sendNtf ("#" <> viewGroupName g <> " " <> viewMemberName m <> "> ", "invited you to join the group") CEvtUserJoinedGroup u g _ -> when (groupNtf u g False) $ do whenCurrUser cc u $ setActiveGroup t g sendNtf ("#" <> viewGroupName g, "you are connected to group") diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index aa509d49bf..e3e58cdf49 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -208,6 +208,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te -- CRGroupConversationsDeleted u _g _conversations -> ttyUser u [] CRGroupsList u gs -> ttyUser u $ viewGroupsList gs CRSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c + CRSentGroupInvitationViaGroup u g _ viaG viaM -> ttyUser u ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyMember viaM <> " in " <> ttyGroup' viaG] CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci CRUserProfile u@User {profile = LocalProfile {localBadge}} p -> ttyUser u $ viewUserProfile localBadge p @@ -514,7 +515,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} in ttyUser u [sShow connId <> ": END"] CEvtSubscriptionStatus srv status conns -> [plain $ subStatusStr status <> " " <> tshow (length conns) <> " connections on server " <> showSMPServer srv] CEvtServiceSubStatus srv event -> [plain $ serviceSubEventStr srv event] - CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r + CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g (ttyContact' c) r + CEvtReceivedGroupInvitationViaGroup {user = u, groupInfo = g, viaGroupInfo = viaG, viaMember = m, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g (ttyMember m <> " in " <> ttyGroup' viaG) r CEvtUserJoinedGroup u g m -> ttyUser u $ viewUserJoinedGroup g m CEvtGroupLinkDataUpdated u g groupLink relays relaysChanged | relaysChanged -> ttyUser u $ viewGroupLinkRelaysUpdated g groupLink relays @@ -732,7 +734,6 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa GroupChat g scopeInfo -> case chatDir of CIGroupSnd -> case content of CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to context mc - CISndGroupInvitation {} -> showSndItemProhibited to _ -> showSndItem to where to = ttyToGroup g scopeInfo @@ -743,7 +744,6 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta CIRcvMsgError err -> viewRcvMsgError from err ts tz meta - CIRcvGroupInvitation {} | isJust m_ -> showRcvItemProhibited from CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False _ -> showRcvItem from @@ -1402,9 +1402,9 @@ viewConnectedToGroupMember g@GroupInfo {groupId} m@GroupMember {groupMemberId, m ] _ -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] -viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString] -viewReceivedGroupInvitation g c role = - ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> showRole role +viewReceivedGroupInvitation :: GroupInfo -> StyledString -> GroupMemberRole -> [StyledString] +viewReceivedGroupInvitation g inviter role = + ttyFullGroup g <> ": " <> inviter <> " invites you to join the group as " <> showRole role : case incognitoMembershipProfile g of Just mp -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile mp)] Nothing -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to accept"] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index bf53060a67..d319d50bf1 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -82,6 +82,7 @@ chatGroupTests = do it "create and delete group" testGroupDelete it "create group with the same displayName" testGroupSameName it "invitee delete group when in status invited" testGroupDeleteWhenInvited + it "invitee join group after deleting inviting contact" testGroupJoinWhenInviterDeleted it "re-add member in status invited" testGroupReAddInvited it "re-add member in status invited, change role" testGroupReAddInvitedChangeRole it "delete contact before they accept group invitation, contact joins group" testGroupDeleteInvitedContact @@ -178,6 +179,15 @@ chatGroupTests = do it "re-create member contact after deletion, many groups" testRecreateMemberContactManyGroups it "manually accept contact with group member" testMemberContactAccept it "manually accept contact with group member incognito" testMemberContactAcceptIncognito + describe "invite member via group" $ do + it "invite member of another group" testInviteMemberViaGroup + it "auto-accept invitation from member of another group" testInviteMemberViaGroupAutoAccept + it "repeat invitation before joining, after joining and after leaving" testInviteMemberViaGroupRepeat + it "join with incognito profile of another group" testInviteMemberViaGroupIncognito + it "reject invitation from member of another group" testInviteMemberViaGroupReject + it "invitation disappears after joining" testInviteMemberViaGroupDisappearing + it "prohibited without direct messages or with wrong group" testInviteMemberViaGroupProhibited + it "prohibited for member with previous version" testInviteMemberViaGroupPrevVersion describe "group message forwarding" $ do it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForwardMessage it "reject forwarded content attributed to own membership" testGroupMsgForwardOwnMembershipRejected @@ -906,6 +916,33 @@ testGroupDeleteWhenInvited = bob <## "use /j team to accept" ] +testGroupJoinWhenInviterDeleted :: HasCallStack => TestParams -> IO () +testGroupJoinWhenInviterDeleted = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/a team bob" + concurrentlyN_ + [ alice <## "invitation to join the group #team sent to bob", + do + bob <## "#team: alice invites you to join the group as member" + bob <## "use /j team to accept" + ] + bob ##> "/d alice" + bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" + bob ##> "/j team" + concurrently_ + (alice <## "#team: bob joined the group") + (bob <## "#team: you joined the group") + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi" + alice <# "#team bob> hi" + testGroupReAddInvited :: HasCallStack => TestParams -> IO () testGroupReAddInvited = testChat2 aliceProfile bobProfile $ @@ -5411,6 +5448,192 @@ testMemberContactAcceptIncognito = cath ?#> ("@" <> bobIncognito <> " hey") bob ?<# (cathIncognito <> "> hey") +testInviteMemberViaGroup :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroup = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + createClub alice + threadDelay 1000000 + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + addBob alice bob bobId "club" + cath ("/_get chat #1 count=1", chat, [(1, "sent invitation to join group club as member")]) + bob #$> ("/_get chat #1 count=1", chat, [(0, "received invitation to join group club as member")]) + bob ##> "/j club" + concurrently_ + (alice <## "#club: bob_1 joined the group") + (bob <## "#club: you joined the group") + checkInvitationStatus bob "accepted" + alice #> "#club hello" + bob <# "#club alice_1> hello" + bob #> "#club hi" + alice <# "#club bob_1> hi" + +testInviteMemberViaGroupAutoAccept :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupAutoAccept = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + bob ##> "/set accept group invitations on" + bob <## "ok" + createClub alice + threadDelay 1000000 + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + alice <## "invitation to join the group #club sent to bob in #team" + concurrently_ + (alice <## "#club: bob_1 joined the group") + (bob <## "#club: you joined the group") + bob #$> ("/_get chat #1 count=1", chat, [(0, "received invitation to join group club as member")]) + +testInviteMemberViaGroupRepeat :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupRepeat = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + addBob alice bob bobId "club" + addBob alice bob bobId "club" + bob ##> "/gs" + bob + <### [ "#team (2 members)", + "#club - you are invited (/j club to join, /d #club to delete invitation)" + ] + bob ##> "/j club" + concurrently_ + (alice <## "#club: bob_1 joined the group") + (bob <## "#club: you joined the group") + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + alice <## "contact bob is already in the group" + bob ##> "/l club" + concurrentlyN_ + [ do + bob <## "#club: you left the group" + bob <## "use /d #club to delete the group", + alice <## "#club: bob_1 left the group (signed)" + ] + addBob alice bob bobId "club_1" + +testInviteMemberViaGroupIncognito :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupIncognito = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c i " <> gLink) + bobIncognito <- getTermLine bob + bob <## "connection request sent incognito!" + alice <## (bobIncognito <> ": accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), + do + bob <## "#team: joining the group..." + bob <## ("#team: you joined the group incognito as " <> bobIncognito) + ] + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" (T.pack bobIncognito) + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + concurrentlyN_ + [ alice <## ("invitation to join the group #club sent to " <> bobIncognito <> " in #team"), + do + bob <## "#club: alice in #team invites you to join the group as member" + bob <## ("use /j club to join incognito as " <> bobIncognito) + ] + bob ##> "/j club" + concurrently_ + (alice <## ("#club: " <> bobIncognito <> "_1 joined the group")) + (bob <## ("#club: you joined the group incognito as " <> bobIncognito)) + +testInviteMemberViaGroupReject :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupReject = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + addBob alice bob bobId "club" + bob ##> "/d #club" + bob <## "#club: you deleted your local copy of the group" + checkInvitationStatus bob "rejected" + +testInviteMemberViaGroupDisappearing :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupDisappearing = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + alice ##> "/set disappear #team on 1" + alice <## "updated group preferences:" + alice <## "Disappearing messages: on (1 sec)" + bob <## "alice updated group #team: (signed)" + bob <## "updated group preferences:" + bob <## "Disappearing messages: on (1 sec)" + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + addBob alice bob bobId "club" + bob ##> "/j club" + concurrently_ + (alice <## "#club: bob_1 joined the group") + ( bob + <### [ "#club: you joined the group", + "timed message deleted: received invitation to join group club as member" + ] + ) + +testInviteMemberViaGroupProhibited :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupProhibited = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + alice ##> "/set direct #team off" + alice <## "updated group preferences:" + alice <## "Direct messages: off" + bob <## "alice updated group #team: (signed)" + bob <## "updated group preferences:" + bob <## "Direct messages: off" + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + alice <## "bad chat command: direct messages not allowed" + alice ##> ("/_add #2 #2 " <> show bobId <> " member") + alice <## ("chat db error: SEGroupMemberNotFound {groupMemberId = " <> show bobId <> "}") + +testInviteMemberViaGroupPrevVersion :: HasCallStack => TestParams -> IO () +testInviteMemberViaGroupPrevVersion ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps testCfgVPrev "bob" bobProfile $ \bob -> do + createGroup2 "team" alice bob + createClub alice + GroupMember {groupMemberId = bobId} <- getTestMember alice "team" "bob" + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + alice <## "peer chat protocol version range incompatible" + +createClub :: HasCallStack => TestCC -> IO () +createClub cc = do + cc ##> "/g club" + cc <## "group #club is created" + cc <## "to add members use /a club or /create link #club" + +addBob :: HasCallStack => TestCC -> TestCC -> GroupMemberId -> String -> IO () +addBob alice bob bobId club = do + alice ##> ("/_add #2 #1 " <> show bobId <> " member") + concurrentlyN_ + [ alice <## "invitation to join the group #club sent to bob in #team", + do + bob <## ("#" <> club <> ": alice in #team invites you to join the group as member") + bob <## ("use /j " <> club <> " to accept") + ] + +checkInvitationStatus :: HasCallStack => TestCC -> String -> IO () +checkInvitationStatus cc status = do + [Only content] <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT item_content FROM chat_items WHERE group_id = 1 ORDER BY chat_item_id DESC LIMIT 1" :: IO [Only String] + content `shouldContain` ("\"status\":\"" <> status <> "\"") + testGroupMsgForwardMessage :: HasCallStack => TestParams -> IO () testGroupMsgForwardMessage = testChat3 aliceProfile bobProfile cathProfile $ From 8ec8c451db15a10bc77621b2811186e750eb49b3 Mon Sep 17 00:00:00 2001 From: nahuhh <50635951+nahuhh@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:06:06 +0000 Subject: [PATCH 09/31] core: fix slow group membership lookup (#7688) --- .../Chat/Store/SQLite/Migrations/chat_query_plans.txt | 4 ++-- src/Simplex/Chat/Store/Shared.hs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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 de3088123c..433c2ec43f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -5876,11 +5876,11 @@ Query: JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? + WHERE g.group_id = ? AND g.user_id = ? AND mu.user_id = ? AND mu.contact_id = ? Plan: SEARCH g USING INTEGER PRIMARY KEY (rowid=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) -SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH mu USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) Query: diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 605d441eb4..75a49090ed 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -954,8 +954,9 @@ getGroupInfoRow db cxt User {userId, userContactId} groupId = ExceptT $ do firstRow (toGroupInfo currentTs cxt userContactId chatTags) (SEGroupNotFound groupId) $ DB.query db - (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") - (groupId, userId, userContactId) + -- mu.user_id steers the planner to idx_group_members_group_id; contact_id index is quadratic over all groups + (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.user_id = ? AND mu.contact_id = ?") + (groupId, userId, userId, userContactId) setPreparedGroupLinkInfo_ :: DB.Connection -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Int64 -> Maybe Int64 -> UTCTime -> IO () setPreparedGroupLinkInfo_ db GroupInfo {groupId, membership} cReq cReqHash customUserProfileId publicMemberCount_ currentTs = do From 64aa2825d5619111bb4d2baeb30cdc3337a7991e Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 11 Oct 2026 22:01:22 +0100 Subject: [PATCH 10/31] core: release core library to simplex-chat-libs every time cabal version changes (#7687) * core: release core library to simplex-chat-libs every time cabal version changes * implement * test build * update libs release * test library release * create release commit, so the date is correct * rework to create release in advance in maybe-release job * remove test build * move postgresql job back * test build * remove test build --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .github/workflows/build.yml | 265 +++++++++++++------------ plans/2026-10-11-core-libs-releases.md | 182 +++++++++++++++++ scripts/desktop/build-lib-linux.sh | 2 +- scripts/desktop/build-lib-mac.sh | 2 +- scripts/desktop/build-lib-windows.sh | 2 +- 5 files changed, 328 insertions(+), 125 deletions(-) create mode 100644 plans/2026-10-11-core-libs-releases.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c20fce8c25..1acc26b6e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,10 +27,6 @@ on: - "scripts/desktop/**" - ".github/**" -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} - # This workflow uses custom actions (prepare-build and prepare-release) defined in: # # .github/actions/ @@ -70,14 +66,26 @@ jobs: # ============================= # Create release, but only if it's triggered by tag push. -# On pull requests/commits push, this job will always complete. +# Create simplex-chat-libs release core-, but only if the head commit of a push to master or stable changes the version in simplex-chat.cabal. +# On pull requests and other pushes, this job will always complete. + +# Secrets: +# ------- +# NODEJS_REPO_TOKEN +# Only select repositories: simplex-chat-libs +# Permissions: +# * Contents (Read and Write) maybe-release: runs-on: ubuntu-latest + outputs: + libs_tag: ${{ steps.libs_commit.outputs.tag }} steps: - name: Clone project - if: startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' uses: actions/checkout@v3 + with: + fetch-depth: 2 - name: Build changelog id: build_changelog @@ -103,6 +111,48 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check version change + id: check_version + if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable' + shell: bash + run: | + version=$(sed -n 's/^version: *//p' simplex-chat.cabal) + prev_version=$(git show HEAD^1:simplex-chat.cabal | sed -n 's/^version: *//p') + if [ "$version" != "$prev_version" ]; then + echo "version=$version" >> $GITHUB_OUTPUT + fi + + - name: Clone libs repo + if: steps.check_version.outputs.version + uses: actions/checkout@v3 + with: + repository: ${{ github.repository }}-libs + token: ${{ secrets.NODEJS_REPO_TOKEN }} + path: libs-repo + + - name: Commit and tag libs release + id: libs_commit + if: steps.check_version.outputs.version + working-directory: libs-repo + run: | + tag=core-${{ steps.check_version.outputs.version }} + printf 'version: %s\ncommit: %s\n' "${{ steps.check_version.outputs.version }}" "${{ github.sha }}" > core-release + git config user.name "SimpleX Chat" + git config user.email "build@simplex.chat" + git add core-release + git commit -m "$tag" + git tag "$tag" + git push --atomic origin HEAD "$tag" + echo "tag=$tag" >> $GITHUB_OUTPUT + + - name: Create libs release + if: steps.check_version.outputs.version + uses: softprops/action-gh-release@v2 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ steps.libs_commit.outputs.tag }} + token: ${{ secrets.NODEJS_REPO_TOKEN }} + # ========================= # Linux Build # ========================= @@ -111,6 +161,9 @@ jobs: name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.runner }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-linux-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.ghc }}-${{ needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} strategy: fail-fast: false matrix: @@ -129,6 +182,7 @@ jobs: should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' + libs: true - os: 24.04 os_underscore: 24_04 arch: x86_64 @@ -143,6 +197,7 @@ jobs: should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' + libs: true - os: 24.04 os_underscore: 24_04 arch: aarch64 @@ -245,6 +300,25 @@ jobs: done strip /out/simplex-chat + - name: Build library + if: needs.maybe-release.outputs.libs_tag && matrix.libs + shell: docker exec -t builder sh -eu {0} + run: | + SKIP_VLC=1 scripts/desktop/build-lib-linux.sh + name=simplex-chat-libs-linux-${{ matrix.arch }} + mkdir -p release-libs/$name/libs + find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-${{ matrix.arch }} -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + (cd release-libs/$name && zip -r ../$name.zip libs) + + - name: Upload library + if: needs.maybe-release.outputs.libs_tag && matrix.libs + uses: softprops/action-gh-release@v2 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ needs.maybe-release.outputs.libs_tag }} + files: release-libs/*.zip + token: ${{ secrets.NODEJS_REPO_TOKEN }} + - name: Build CLI deb if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: docker exec -t builder sh -eu {0} @@ -398,7 +472,7 @@ jobs: name: "ubuntu-22.04-x86_64 (Postgres lib), GHC: ${{ needs.variables.outputs.GHC_VER }}" needs: [maybe-release, variables] runs-on: ubuntu-22.04 - if: startsWith(github.ref, 'refs/tags/v') + if: needs.maybe-release.outputs.libs_tag steps: - name: Checkout Code uses: actions/checkout@v3 @@ -468,15 +542,18 @@ jobs: ARCH=x86_64 GHC_VER=${{ needs.variables.outputs.GHC_VER }} BUILD_DIR=$(echo dist-newstyle/build/${ARCH}-linux/ghc-${GHC_VER}/simplex-chat-*) - mkdir -p postgres-libs - cp ${BUILD_DIR}/build/libsimplex.so postgres-libs/ - cp ${BUILD_DIR}/build/deps/* postgres-libs/ + mkdir -p postgres-libs/libs + cp ${BUILD_DIR}/build/libsimplex.so postgres-libs/libs/ + cp ${BUILD_DIR}/build/deps/* postgres-libs/libs/ + (cd postgres-libs && zip -r simplex-chat-libs-linux-x86_64-postgres.zip libs) - - name: Upload postgres libs artifact - uses: actions/upload-artifact@v4 + - name: Upload postgres library + uses: softprops/action-gh-release@v2 with: - name: simplex-libs-linux-postgres-x86_64 - path: postgres-libs/ + repository: ${{ github.repository }}-libs + tag_name: ${{ needs.maybe-release.outputs.libs_tag }} + files: postgres-libs/*.zip + token: ${{ secrets.NODEJS_REPO_TOKEN }} - name: Fix permissions for cache shell: bash @@ -492,16 +569,21 @@ jobs: name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.os }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-macos-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} strategy: fail-fast: false matrix: include: - os: macos-latest + arch: aarch64 ghc: ${{ needs.variables.outputs.GHC_VER }} cli_asset_name: simplex-chat-macos-aarch64 desktop_asset_name: simplex-desktop-macos-aarch64.dmg openssl_dir: "/opt/homebrew/opt" - os: macos-15-intel + arch: x86_64 ghc: ${{ needs.variables.outputs.GHC_VER }} cli_asset_name: simplex-chat-macos-x86-64 desktop_asset_name: simplex-desktop-macos-x86_64.dmg @@ -528,7 +610,7 @@ jobs: run: | echo "ignore-project: False" >> cabal.project.local echo "package simplexmq" >> cabal.project.local - echo " extra-include-dirs: ${{ matrix.opnessl_dir }}/openssl@3.0/include" >> cabal.project.local + echo " extra-include-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/include" >> cabal.project.local echo " extra-lib-dirs: ${{ matrix.openssl_dir}}/openssl@3.0/lib" >> cabal.project.local echo "" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local @@ -601,6 +683,26 @@ jobs: exit 1 fi + - name: Build library + id: mac_lib_build + if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag && steps.mac_cli_build.outcome == 'success' }} + shell: bash + run: | + SKIP_VLC=1 scripts/desktop/build-lib-mac.sh + name=simplex-chat-libs-macos-${{ matrix.arch }} + mkdir -p release-libs/$name/libs + find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-${{ matrix.arch }} -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + (cd release-libs/$name && zip -r ../$name.zip libs) + + - name: Upload library + if: ${{ !cancelled() && steps.mac_lib_build.outcome == 'success' }} + uses: softprops/action-gh-release@v2 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ needs.maybe-release.outputs.libs_tag }} + files: release-libs/*.zip + token: ${{ secrets.NODEJS_REPO_TOKEN }} + # ========================= # Windows Build # ========================= @@ -609,6 +711,9 @@ jobs: name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.os }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-windows-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') && !needs.maybe-release.outputs.libs_tag }} strategy: fail-fast: false matrix: @@ -648,6 +753,7 @@ jobs: git perl make + zip pacboy: >- toolchain:p cmake:p @@ -676,6 +782,28 @@ jobs: echo "bin_path=$path" >> $GITHUB_OUTPUT echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + - name: Build library + if: needs.maybe-release.outputs.libs_tag + shell: msys2 {0} + run: | + export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) + SKIP_VLC=1 scripts/desktop/build-lib-windows.sh + dlltool -d libsimplex.dll.def -l libsimplex.lib -D libsimplex.dll + name=simplex-chat-libs-windows-x86_64 + mkdir -p release-libs/$name/libs + find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64 -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + cp libsimplex.lib release-libs/$name/libs/ + (cd release-libs/$name && zip -r ../$name.zip libs) + + - name: Upload library + if: needs.maybe-release.outputs.libs_tag + uses: softprops/action-gh-release@v2 + with: + repository: ${{ github.repository }}-libs + tag_name: ${{ needs.maybe-release.outputs.libs_tag }} + files: release-libs/*.zip + token: ${{ secrets.NODEJS_REPO_TOKEN }} + - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') uses: ./.github/actions/prepare-release @@ -709,110 +837,3 @@ jobs: bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }} github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} - -# ========================= -# NodeJS libs release -# ========================= - -# Downloads Desktop builds, extracts and archives libraries for NodeJS addon. -# Depends on Linux/MacOS, executes only on release. - -# Secrets: -# ------- -# NODEJS_REPO_TOKEN -# Only select repositories: simplex-chat-libs -# Permissions: -# * Contents (Read and Write) - - release-nodejs-libs: - runs-on: ubuntu-latest - needs: [build-linux, build-linux-postgres, build-macos] - if: startsWith(github.ref, 'refs/tags/v') && (!cancelled()) - steps: - - name: Checkout current repository - uses: actions/checkout@v6 - - - name: Install packages for archiving - run: sudo apt install -y msitools gcc-mingw-w64 - - - name: Download postgres libs artifact - if: needs.build-linux-postgres.result == 'success' - uses: actions/download-artifact@v4 - with: - name: simplex-libs-linux-postgres-x86_64 - path: ${{ runner.temp }}/postgres-libs - - - name: Build archives - run: | - INIT_DIR='${{ runner.temp }}/artifacts' - RELEASE_DIR='${{ runner.temp }}/release-assets' - TAG='${{ github.ref_name }}' - URL='https://github.com/${{ github.repository }}/releases/download' - PREFIX='${{ github.event.repository.name }}-libs' - # Windows-specific - FILE_URL='https://raw.githubusercontent.com/${{ github.repository }}/refs/tags/${{ github.ref_name }}' - - # Setup directories - mkdir "$INIT_DIR" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Downlaod desktop release - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-ubuntu-22_04-x86_64.deb" -o linux.deb - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-macos-aarch64.dmg" -o macos-aarch64.dmg - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-macos-x86_64.dmg" -o macos-x86_64.dmg - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-windows-x86_64.msi" -o windows-x86_64.msi - - # Linux - # ----- - # Extract libraries - dpkg-deb -R linux.deb linux-out/ && cd linux-out/opt/simplex/lib/app/resources - # Preprare directory - mkdir libs && cp *.so libs/ - # Archive - zip -r "${PREFIX}-linux-x86_64.zip" libs - # Back to original dir - mv "${PREFIX}-linux-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # MacOS: aarch64 - # -------------- - 7z x macos-aarch64.dmg -omacos1-out/ && cd macos1-out/SimpleX/SimpleX.app/Contents/app/resources/ - mkdir libs && cp *.dylib libs/ - zip -r "${PREFIX}-macos-aarch64.zip" libs - mv "${PREFIX}-macos-aarch64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Macos: x86_64 - # ------------- - 7z x macos-x86_64.dmg -omacos2-out/ && cd macos2-out/SimpleX/SimpleX.app/Contents/app/resources/ - mkdir libs && cp *.dylib libs/ - zip -r "${PREFIX}-macos-x86_64.zip" libs - mv "${PREFIX}-macos-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Windows: x86_64 - # --------------- - msiextract windows-x86_64.msi -C windows-out && cd windows-out/SimpleX/app/resources - - # We need to generate library that exports symbols from Windows dll - curl --proto '=https' --tlsv1.2 -sSf -LO "${FILE_URL}/libsimplex.dll.def" - x86_64-w64-mingw32-dlltool -d libsimplex.dll.def -l libsimplex.lib -D libsimplex.dll - - mkdir libs && cp *.dll *.lib libs/ - zip -r "${PREFIX}-windows-x86_64.zip" libs - mv "${PREFIX}-windows-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Linux PostgreSQL (only if postgres build succeeded) - # ------------------------------------------------- - POSTGRES_LIBS='${{ runner.temp }}/postgres-libs' - if [ -d "$POSTGRES_LIBS" ]; then - mkdir -p linux-postgres/libs - cp "${POSTGRES_LIBS}"/*.so linux-postgres/libs/ - cd linux-postgres - zip -r "${PREFIX}-linux-x86_64-postgres.zip" libs - mv "${PREFIX}-linux-x86_64-postgres.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - fi - - - name: Create release in libs repo and upload artifacts - uses: softprops/action-gh-release@v2 - with: - repository: ${{ github.repository }}-libs - tag_name: ${{ github.ref_name }} - files: ${{ runner.temp }}/release-assets/* - token: ${{ secrets.NODEJS_REPO_TOKEN }} diff --git a/plans/2026-10-11-core-libs-releases.md b/plans/2026-10-11-core-libs-releases.md new file mode 100644 index 0000000000..e6f9a1347b --- /dev/null +++ b/plans/2026-10-11-core-libs-releases.md @@ -0,0 +1,182 @@ +# Core library releases: implementation plan + +Libraries for the Node.js package are released per core version, from the commit on `master` or `stable` that changes the version. + +## 1. Release identity + +``` +repository simplex-chat/simplex-chat-libs +tag core- +version version: field of simplex-chat.cabal, e.g. 7.1.0.10 +build push to master or stable, head commit changes version: compared with its first parent +source the head commit of that push +``` + +A version change that is not the head commit of its push is not released. + +Rule: a core version is final once a published package depends on it. +Its migrations and chat protocol versions stay unchanged. +A further change requires a new cabal version. + +## 2. Assets + +| Asset | Job | Backend | +|---|---|---| +| `simplex-chat-libs-linux-x86_64.zip` | `build-linux`, 22.04 x86_64 | sqlite | +| `simplex-chat-libs-linux-x86_64-postgres.zip` | `build-linux-postgres` | postgres | +| `simplex-chat-libs-linux-aarch64.zip` | `build-linux`, 22.04 aarch64 | sqlite | +| `simplex-chat-libs-macos-aarch64.zip` | `build-macos`, `macos-latest` | sqlite | +| `simplex-chat-libs-macos-x86_64.zip` | `build-macos`, `macos-15-intel` | sqlite | +| `simplex-chat-libs-windows-x86_64.zip` | `build-windows` | sqlite | + +Zip content: directory `libs/` with the top-level files of `apps/multiplatform/common/src/commonMain/cpp/desktop/libs/-/`. +Windows zip: also `libs/libsimplex.lib`. +Asset names: as built by `download-libs.js` (line 123). +Checksums: `digest` (sha256) of each asset in the GitHub release API. + +## 3. `build.yml` + +### 3.1. `maybe-release` + +The release is created before the build jobs; each build job uploads its assets, as for `v*` tags. + +``` +outputs libs_tag: tag of the created library release, empty otherwise +Clone project condition: push event; fetch-depth: 2 +``` + +Steps after "Create release": + +| Step | Condition | Action | +|---|---|---| +| Check version change | ref `master` or `stable` | output `version` when `version:` of `simplex-chat.cabal` differs from `HEAD^1:simplex-chat.cabal` | +| Clone libs repo | `version` set | checkout of simplex-chat-libs to `libs-repo`, token `NODEJS_REPO_TOKEN` | +| Commit and tag libs release | `version` set | commit and tag below; `git push --atomic origin HEAD `; output `tag` | +| Create libs release | `version` set | `softprops/action-gh-release@v2`, repository simplex-chat-libs, `tag_name` = `tag`, without files | + +Commit in simplex-chat-libs: + +``` +file core-release +content version: + commit: +author SimpleX Chat +message core- +tag core-, on this commit +``` + +`NODEJS_REPO_TOKEN`: a token of the build account; releases are attributed to the token owner. +Secrets comment: above `maybe-release`. + +A failure of `maybe-release` skips the build jobs, as for `v*` tags. + +### 3.2. Concurrency + +Workflow-level `concurrency`: removed. + +Job-level `concurrency` in `build-linux`, `build-macos`, `build-windows`: + +``` +group ${{ github.workflow }}-${{ github.ref }}---${{ needs.maybe-release.outputs.libs_tag }} +cancel-in-progress not a v* tag and libs_tag empty +``` + +Library release runs are never cancelled; other runs are cancelled by the next push to the same ref. + +### 3.3. Library steps + +| Job | Entries | Position | Build | +|---|---|---|---| +| `build-linux` | 22.04 x86_64 and 22.04 aarch64, GHC 9.6.3 (matrix key `libs: true`) | after "Build CLI" | `SKIP_VLC=1 scripts/desktop/build-lib-linux.sh` | +| `build-macos` | both (matrix key `arch`) | after "Run tests" | `SKIP_VLC=1 scripts/desktop/build-lib-mac.sh` | +| `build-windows` | `windows-latest` | after "Build CLI" | `SKIP_VLC=1 scripts/desktop/build-lib-windows.sh`; `dlltool -d libsimplex.dll.def -l libsimplex.lib -D libsimplex.dll` | + +Each build step: staging in `release-libs//libs/`, then `zip -r` to `release-libs/.zip`. +Linux steps run in the build container. +Windows: `zip` added to the MSYS2 `install` list. + +Upload step, after the build steps of the job: + +``` +action softprops/action-gh-release@v2 +repository ${{ github.repository }}-libs +tag_name needs.maybe-release.outputs.libs_tag +files release-libs/*.zip +token secrets.NODEJS_REPO_TOKEN +``` + +Conditions: + +| Job | Build | Upload | +|---|---|---| +| `build-linux` | `libs_tag` set, `matrix.libs` | `libs_tag` set, `matrix.libs` | +| `build-macos` | `!cancelled()`, `libs_tag` set, CLI build succeeded | `!cancelled()`, library build succeeded | +| `build-windows` | `libs_tag` set | `libs_tag` set | + +A failed job leaves its assets absent; the other jobs upload theirs. +A re-run of a failed job uploads its assets to the same release; an asset of the same name is replaced. + +### 3.4. `build-linux-postgres` + +Edits of the existing job: + +``` +if needs.maybe-release.outputs.libs_tag, replaces the v* tag condition +Copy libs from container + files copied to postgres-libs/libs/ + zip -r simplex-chat-libs-linux-x86_64-postgres.zip libs, in postgres-libs +Upload postgres library + replaces the artifact upload + softprops/action-gh-release@v2, files postgres-libs/*.zip, other inputs as in 3.3 +``` + +## 4. Scripts: `SKIP_VLC` + +With `SKIP_VLC` set, the VLC download is skipped. +Unset: unchanged. + +| File | Line | After | +|---|---|---| +| `scripts/desktop/build-lib-linux.sh` | 56 | `[ -n "$SKIP_VLC" ] \|\| scripts/desktop/prepare-vlc-linux.sh` | +| `scripts/desktop/build-lib-mac.sh` | 154 | `[ -n "$SKIP_VLC" ] \|\| scripts/desktop/prepare-vlc-mac.sh` | +| `scripts/desktop/build-lib-windows.sh` | 64 | `[ -n "$SKIP_VLC" ] \|\| scripts/desktop/prepare-vlc-windows.sh` | + +## 5. `build.yml`: removed job + +`release-nodejs-libs`, with its header comment. + +## 6. Node.js package + +``` +download-libs.js RELEASE_TAG = 'core-', set in the Node.js release commit, once release core- exists +package.json version: Node.js release cycle +release commits RELEASE_TAG removed from their edits (author build@simplex.chat) +``` + +This change: `RELEASE_TAG` unchanged, `'v7.1.0-beta.6'`. + +Platforms: `linux-aarch64` added by the assets in section 2; `download-libs.js` maps `arm64` to `aarch64` (lines 46-50). +Postgres: Linux x86_64 only (lines 19-22). + +## 7. Precompiled add-on (`ep/node-add-on`) + +Add-on releases: separate from library releases, triggered by changes of the Node.js package version, designed in `ep/node-add-on`. + +Effects of this plan on `ep/node-add-on`: + +``` +release-nodejs-libs removed; its add-on upload edits move to the add-on release +RELEASE_TAG library release core-; add-on assets in a separate release +SHA256 library values from the asset digests of release core- +``` + +## 8. `build.yml` fix + +Line 531: `matrix.opnessl_dir` → `matrix.openssl_dir`. + +## 9. Verification + +1. Branch run of `build.yml` with temporary edits: `push` trigger and "Check version change" condition with the branch, tag `core-test-`, cabal version increment in the head commit of the push. +2. `npm install` of the Node.js package with `RELEASE_TAG = 'core-test-'` on Linux x86_64, Linux aarch64, macOS aarch64, macOS x86_64, Windows x86_64. +3. Postgres: `SIMPLEX_BACKEND=postgres npm install` on Linux x86_64. +4. Release `core-test-` and its tag deleted after verification. diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index a2684b87d2..6ee419c5a8 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -53,7 +53,7 @@ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp -r $BUILD_DIR/build/deps/* apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libsimplex.so apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -scripts/desktop/prepare-vlc-linux.sh +[ -n "$SKIP_VLC" ] || scripts/desktop/prepare-vlc-linux.sh links_dir=apps/multiplatform/build/links mkdir -p $links_dir diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 6013fc55c0..5563086c29 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -151,7 +151,7 @@ if [ -n "$LOCAL_DIRS" ]; then fi cd - -scripts/desktop/prepare-vlc-mac.sh +[ -n "$SKIP_VLC" ] || scripts/desktop/prepare-vlc-mac.sh links_dir=apps/multiplatform/build/links mkdir -p $links_dir diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index fdf7154491..15e021adf7 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -61,7 +61,7 @@ mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp dist-newstyle/openssl-3.0.15/libcrypto-3-x64.dll apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp libsimplex.dll apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -scripts/desktop/prepare-vlc-windows.sh +[ -n "$SKIP_VLC" ] || scripts/desktop/prepare-vlc-windows.sh links_dir=apps/multiplatform/build/links mkdir -p $links_dir From 9711dcfc9c39ba125c1fb1674a3e039b1b9c0e81 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 11 Oct 2026 23:01:35 +0100 Subject: [PATCH 11/31] core: 7.1.0.11 (simplexmq 7.1.0.11) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- tests/ChatClient.hs | 1 + 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 9d5f1b8e21..fea595d685 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: a2f985dc68f870c0327fc3e871f2816828bcba2d + tag: cba13823567d7cd564d8a1f354046174fba375d8 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 43573e82cf..49ae96bb8f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a2f985dc68f870c0327fc3e871f2816828bcba2d" = "12a66936nliggq8k2pmk8x2lilrhvlg15rsmcv556s8gpzxjavdw"; + "https://github.com/simplex-chat/simplexmq.git"."cba13823567d7cd564d8a1f354046174fba375d8" = "0nhd6zsmqs55b7shh945b0v9pn0qqkpx9djq9dldair82hsmw1if"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."2330df3dc8c4b660674d3c02d76bb86f66c2abee" = "11pfj1vqsjdl26nplkr2l6z1xj7cn153qijj5gknmlzq3krn7x1i"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 0348469060..c9a29b3ac8 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: 7.1.0.10 +version: 7.1.0.11 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 35e9d6d372..63886dcadb 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 [7, 1, 0, 9] +minRemoteCtrlVersion = AppVersion [7, 1, 0, 11] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [7, 1, 0, 9] +minRemoteHostVersion = AppVersion [7, 1, 0, 11] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 4a052d7da5..0593dc3554 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -670,6 +670,7 @@ smpServerCfg p = prometheusInterval = Nothing, prometheusMetricsFile = "tests/smp-server-metrics.txt", pendingENDInterval = 500000, + authErrorDelay = 10, ntfDeliveryInterval = 200000, smpServerVRange = supportedServerSMPRelayVRange, transportConfig = mkTransportServerConfig True (Just alpnSupportedSMPHandshakes) True, From 599076d7a200a2aeac8bea4ba91cc1fc5e58d584 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 12 Oct 2026 09:14:37 +0100 Subject: [PATCH 12/31] packages: update nodejs libraries (#7690) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- packages/simplex-chat-client/types/typescript/package.json | 2 +- packages/simplex-chat-nodejs/package.json | 4 ++-- packages/simplex-chat-nodejs/src/download-libs.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 24ea58b66b..7bf43e0840 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.12.0", + "version": "0.12.1", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index f80289d33b..f3ab195b9b 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.6", + "version": "7.1.0-beta.6.1", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.11.4", + "@simplex-chat/types": "^0.12.1", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index a278ee27dc..fc4560fe81 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.1.0-beta.6'; +const RELEASE_TAG = 'core-7.1.0.11'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); // A locally built libsimplex, copied into libs/ rather than loaded in place: // the addon's RUNPATH is $ORIGIN/../../libs. From eda94a505de1d3a9a43f6b2938287b9687b56a95 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 12 Oct 2026 13:26:48 +0100 Subject: [PATCH 13/31] nodejs: send error events to bot (#7691) * nodejs: send error events to bot * version --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- packages/simplex-chat-nodejs/package.json | 2 +- packages/simplex-chat-nodejs/src/api.ts | 14 +++++++------- .../simplex-chat-nodejs/tests/api.unit.test.ts | 10 ++++++++++ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index f3ab195b9b..a482bafae8 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.6.1", + "version": "7.1.0-beta.6.2", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 8e0c5a545d..95d942ebf1 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -80,6 +80,11 @@ export type DbConfig = connectionString: string } +function chatErrorEvent(err: unknown): ChatEvent { + if (err instanceof core.ChatAPIError && err.chatError) return {type: "chatError", chatError: err.chatError} + throw err +} + function dbConfigToMigrateArgs(db: DbConfig): [string, string] { switch (db.type) { case "sqlite": @@ -171,7 +176,7 @@ export class ChatApi { private async runEventsLoop(): Promise { while (this.receiveEvents) { try { - const event = await this.recvChatEvent() + const event = await this.recvChatEvent().catch(chatErrorEvent) if (!event) continue const subs = this.subscribers[event.type] if (subs) { @@ -194,12 +199,7 @@ export class ChatApi { } } } catch(err) { - const e = err as core.ChatAPIError - if ("chatError" in e) { - console.log("Chat error", e.chatError) - } else { - console.log("Invalid event", e) - } + console.log("Invalid event", err) } } } diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts index b6b92bf93b..8bfecc3347 100644 --- a/packages/simplex-chat-nodejs/tests/api.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts @@ -110,6 +110,16 @@ describe("startChat lifecycle", () => { await chat.stopChat() }) + it("passes event errors to chatError subscribers", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"}) + const chatError = {type: "errorStore", storeError: {type: "connectionNotFound", agentConnId: "Y29ubg=="}} as T.ChatError + jest.mocked(core.chatRecvMsgWait).mockRejectedValueOnce(new core.ChatAPIError("Chat event error (see chatError property)", chatError)) + const received = new Promise(resolve => chat.on("chatError", event => resolve(event))) + await chat.startChat() + await expect(received).resolves.toEqual({type: "chatError", chatError}) + await chat.stopChat() + }) + it("receives with a 500 ms wait", async () => { const chat = await chatWithResponses() await chat.recvChatEvent() From eadebccb4f68bffc492a5e1f4076c9d9951b4082 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 12 Oct 2026 22:59:42 +0100 Subject: [PATCH 14/31] nodejs: send error events to bot (#7691) * nodejs: send error events to bot * version --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> From 4b6ac88c6e808f5d22fe5fcb6937b55d91b2bc98 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 13 Oct 2026 08:40:32 +0100 Subject: [PATCH 15/31] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index ccbdaadc5c..a138b24e4e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -193,8 +193,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-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.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 */; }; @@ -586,8 +586,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-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.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 = ""; }; @@ -760,8 +760,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -848,8 +848,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.10-1nt2m8GM0NE8NSirknFyt8.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */, ); path = Libraries; sourceTree = ""; From d673a6b02136018e49f0796da74e089ad49a0446 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 13 Oct 2026 08:42:59 +0100 Subject: [PATCH 16/31] ui: show unicode characters in address as unicode markers (#7698) * ui: show unicode characters in address as unicode markers * defence in depth for links in messages (they are non-clickable from parser) --------- Current code does not have this problem, as markdown parser rejects such links, and they are not clickable - it is needed for new markdown file viewer - this way unicode links in markdown show with unicode sequences instead of look-alike letters. --- .../Views/Chat/ChatItem/CILinkView.swift | 26 ++++++++++++++++--- .../Views/Chat/ChatItem/MsgContentView.swift | 1 + .../ios/Shared/Views/Helpers/ShareSheet.swift | 2 +- .../common/views/chat/item/TextItemView.kt | 12 ++++++--- .../simplex/common/views/helpers/Utils.kt | 18 +++++++++++-- 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 4eb187bcac..3433115ceb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -52,7 +52,7 @@ struct CILinkView: View { func openBrowserAlert(uri: String) { let (url, err) = sanitizeUri(uri) if let url { - let uriStr = url.uri.absoluteString + let uriStr = displayedUri(url.uri.absoluteString) showAlert( NSLocalizedString("Open link?", comment: "alert title"), message: uriStr.count > 160 ? "\(uriStr.prefix(160))…" : uriStr, @@ -90,9 +90,9 @@ func openBrowserAlert(uri: String) { func showInvalidLinkAlert(_ uri: String, error: String? = nil) { let message = if let error, !error.isEmpty { - error + "\n" + uri + error + "\n" + displayedUri(uri) } else { - uri + displayedUri(uri) } showAlert( NSLocalizedString("Invalid link", comment: "alert title"), @@ -101,6 +101,26 @@ func showInvalidLinkAlert(_ uri: String, error: String? = nil) { ) } +func unicodeMarker(_ s: Unicode.Scalar) -> String { + String(format: "[U+%04X]", s.value) +} + +func displayedUri(_ uri: String) -> String { + let authority = uri.range(of: "://").map { scheme in + scheme.upperBound ..< (uri[scheme.upperBound...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex) + } + var shown = "" + for i in uri.unicodeScalars.indices { + let s = uri.unicodeScalars[i] + if s.properties.generalCategory == .format || (!s.isASCII && authority?.contains(i) == true) { + shown += unicodeMarker(s) + } else { + shown.unicodeScalars.append(s) + } + } + return shown +} + func sanitizeUri(_ s: String) -> (url: (uri: URL, sanitizedUri: URL?)?, error: String?) { let parsed = parseSanitizeUri(s, safe: false) return if let uri = URL(string: s), let uriInfo = parsed?.uriInfo { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index c9d8858fa1..f95e31369e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -490,6 +490,7 @@ func messageText( attrs[webLinkAttrKey] = true handleTaps = true } + t = displayedUri(t) case let .hyperLink(text, uri): attrs = linkAttrs() if let text { t = text } diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 56e437e5c8..ebe74b2a50 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -98,7 +98,7 @@ func openExternalLink(_ url: URL) { } else { showAlert( title: NSLocalizedString("Open external link?", comment: "alert title"), - message: s, + message: displayedUri(s), buttonTitle: NSLocalizedString("Open", comment: "alert button"), buttonAction: { UIApplication.shared.open(url) }, cancelButton: true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index da64862c03..c198451464 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -96,6 +96,7 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin } else if (mention != null) mentionText(ft.format.memberName) else ft.text } + is Format.Uri -> displayedUri(ft.text) is Format.HyperLink -> ft.format.showText ?: ft.text is Format.SimplexLink -> { val t = ft.format.showText @@ -283,7 +284,7 @@ fun MarkdownText ( val s = ft.text val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s" withAnnotation(tag = "WEB_URL", annotation = link) { - withStyle(ftStyle) { append(ft.text) } + withStyle(ftStyle) { append(displayedUri(ft.text)) } } } is Format.HyperLink -> { @@ -535,19 +536,22 @@ fun openBrowserAlert(uri: String, uriHandler: UriHandler) { if (res == null) { showInvalidLinkAlert(uri, err) } else { - val message = if (uri.count() > 160) uri.substring(0, 159) + "…" else uri + val shownUri = displayedUri(uri) + val message = if (shownUri.count() > 160) shownUri.substring(0, 159) + "…" else shownUri val sanitizedUri = res.second if (sanitizedUri == null) { AlertManager.shared.showAlertDialog( generalGetString(MR.strings.privacy_chat_list_open_web_link_question), message, confirmText = generalGetString(MR.strings.open_verb), - onConfirm = { safeOpenUri(uri, uriHandler) } + onConfirm = { safeOpenUri(uri, uriHandler) }, + parseHtml = false ) } else { AlertManager.shared.showAlertDialogButtonsColumn( generalGetString(MR.strings.privacy_chat_list_open_web_link_question), message, + parseHtml = false, buttons = { Column { SectionItemView({ @@ -585,7 +589,7 @@ fun safeOpenUri(uri: String, uriHandler: UriHandler) { } fun showInvalidLinkAlert(uri: String, error: String? = null) { - val message = if (error.isNullOrEmpty()) { uri } else { error + "\n" + uri } + val message = if (error.isNullOrEmpty()) { displayedUri(uri) } else { error + "\n" + displayedUri(uri) } AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_parsing_uri_title), message) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index dfe33e3595..6b69a3820f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -617,13 +617,27 @@ fun UriHandler.openExternalLink(uri: String) { } else { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.open_external_link_title), - text = uri, + text = displayedUri(uri), confirmText = generalGetString(MR.strings.open_verb), - onConfirm = { uriHandler.openUriCatching(uri) } + onConfirm = { uriHandler.openUriCatching(uri) }, + parseHtml = false ) } } +val invisibleChar = Regex("\\p{Cf}") + +private val nonAsciiChar = Regex("[^\\x00-\\x7F]") + +fun unicodeMarker(c: String): String = "[U+" + Integer.toHexString(c.codePointAt(0)).uppercase().padStart(4, '0') + "]" + +fun displayedUri(uri: String): String { + val authorityStart = uri.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: uri.length + val authorityEnd = uri.indexOfAny(charArrayOf('/', '?', '#'), authorityStart).takeIf { it >= 0 } ?: uri.length + val authority = uri.substring(authorityStart, authorityEnd).replace(nonAsciiChar) { unicodeMarker(it.value) } + return uri.replaceRange(authorityStart, authorityEnd, authority).replace(invisibleChar) { unicodeMarker(it.value) } +} + fun IntSize.Companion.Saver(): Saver = Saver( save = { it.width to it.height }, restore = { IntSize(it.first, it.second) } From 1bd7c95006a82a92e8d19d233d0d37e72ae8ba24 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 13 Oct 2026 08:53:04 +0100 Subject: [PATCH 17/31] ui: additional check for plain domains (#7699) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift | 7 +++---- .../kotlin/chat/simplex/common/views/helpers/Utils.kt | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 3433115ceb..42259361b7 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -106,13 +106,12 @@ func unicodeMarker(_ s: Unicode.Scalar) -> String { } func displayedUri(_ uri: String) -> String { - let authority = uri.range(of: "://").map { scheme in - scheme.upperBound ..< (uri[scheme.upperBound...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex) - } + let authorityStart = uri.range(of: "://")?.upperBound ?? uri.startIndex + let authority = authorityStart ..< (uri[authorityStart...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex) var shown = "" for i in uri.unicodeScalars.indices { let s = uri.unicodeScalars[i] - if s.properties.generalCategory == .format || (!s.isASCII && authority?.contains(i) == true) { + if s.properties.generalCategory == .format || (!s.isASCII && authority.contains(i)) { shown += unicodeMarker(s) } else { shown.unicodeScalars.append(s) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 6b69a3820f..81a64cb527 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -632,7 +632,7 @@ private val nonAsciiChar = Regex("[^\\x00-\\x7F]") fun unicodeMarker(c: String): String = "[U+" + Integer.toHexString(c.codePointAt(0)).uppercase().padStart(4, '0') + "]" fun displayedUri(uri: String): String { - val authorityStart = uri.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: uri.length + val authorityStart = uri.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: 0 val authorityEnd = uri.indexOfAny(charArrayOf('/', '?', '#'), authorityStart).takeIf { it >= 0 } ?: uri.length val authority = uri.substring(authorityStart, authorityEnd).replace(nonAsciiChar) { unicodeMarker(it.value) } return uri.replaceRange(authorityStart, authorityEnd, authority).replace(invisibleChar) { unicodeMarker(it.value) } From 7d870c6ff515d97ed93b81d2964ffd9f310d82f4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 13 Oct 2026 11:24:14 +0100 Subject: [PATCH 18/31] ui: open file after tap when it is received (#7700) * ui: open file after tap when it is received * fix ios * simplify * simplify * better --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 17 +++++ apps/ios/Shared/Model/SimpleXAPI.swift | 3 + .../Views/Chat/ChatItem/CIFileView.swift | 7 +- .../Views/Chat/ChatItem/CIImageView.swift | 1 + .../Views/Chat/ChatItem/CIVideoView.swift | 20 ++++++ .../Views/Chat/ChatItem/CIVoiceView.swift | 5 ++ .../chat/simplex/common/model/ChatModel.kt | 15 +++++ .../common/views/chat/item/CIFileView.kt | 24 +++++-- .../common/views/chat/item/CIImageView.kt | 10 +++ .../common/views/chat/item/CIVideoView.kt | 66 +++++++++++-------- .../common/views/chat/item/CIVoiceView.kt | 3 +- 11 files changed, 132 insertions(+), 39 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 7c1e8e65ab..1ee79e1c4f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -474,10 +474,27 @@ final class ChatModel: ObservableObject { // another in its place; only the badge alert shows regardless. Set while rendering, so not published. var chatListBanner: ChatListBanner? + var fileToOpen: (fileId: Int64, chatId: String?, open: (CIFile) -> Void)? + func bannerSlotFree(for banner: ChatListBanner) -> Bool { chatListBanner == nil || chatListBanner == banner } + func openWhenReceived(_ file: CIFile, open: @escaping (CIFile) -> Void) { + fileToOpen = (fileId: file.fileId, chatId: chatId, open: open) + } + + func receivedFileToOpen(_ file: CIFile) -> Bool { + if let f = fileToOpen { f.fileId == file.fileId && f.chatId == chatId } else { false } + } + + func openReceivedFile(_ file: CIFile) { + if let f = fileToOpen, receivedFileToOpen(file) { + fileToOpen = nil + f.open(file) + } + } + static let shared = ChatModel() let im = ItemsModel.shared diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d04ccf5b03..3ec9c34f8e 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2873,6 +2873,9 @@ func processReceivedMsg(_ res: ChatEvent) async { await chatItemSimpleUpdate(user, aChatItem) case let .rcvFileComplete(user, aChatItem): await chatItemSimpleUpdate(user, aChatItem) + if active(user), let file = aChatItem.chatItem.file, m.receivedFileToOpen(file) { + await MainActor.run { m.openReceivedFile(file) } + } case let .rcvFileSndCancelled(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 8bb76ca155..59ab1c24e0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -26,7 +26,7 @@ struct CIFileView: View { var body: some View { if smallViewSize != nil { fileIndicator() - .simultaneousGesture(TapGesture().onEnded(fileAction)) + .simultaneousGesture(TapGesture().onEnded { fileAction(file) }) } else { // reserve exact space for the overlaid meta (timestamp + all icons), rendered transparently - matches MsgContentView let encrypted: Bool? = if let fileSource = file?.fileSource { fileSource.cryptoArgs != nil } else { nil } @@ -56,7 +56,7 @@ struct CIFileView: View { .padding(.bottom, 6) .padding(.leading, 10) .padding(.trailing, 12) - .simultaneousGesture(TapGesture().onEnded(fileAction)) + .simultaneousGesture(TapGesture().onEnded { fileAction(file) }) .disabled(!itemInteractive) } } @@ -85,7 +85,7 @@ struct CIFileView: View { return false } - private func fileAction() { + private func fileAction(_ file: CIFile?) { logger.debug("CIFileView fileAction") if let file = file { switch (file.fileStatus) { @@ -93,6 +93,7 @@ struct CIFileView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file) { fileAction($0) } Task { logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") if let user = m.currentUser { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index fb6ccb1ea3..469fc5a3fc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -54,6 +54,7 @@ struct CIImageView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file) { _ in showFullScreenImage = true } Task { if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 0df17dfe3d..339d119362 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -120,6 +120,25 @@ struct CIVideoView: View { } } + private func playReceived(_ file: CIFile) { + setupPlayer(file) + if urlDecrypted == nil { + decrypt(file: file, completed: playVideo) + } else { + playVideo() + } + } + + private func playVideo() { + if smallView { + showFullScreenPlayer = urlDecrypted != nil + } else if let urlDecrypted { + m.stopPreviousRecPlay = urlDecrypted + videoPlaying = true + player?.play() + } + } + private func setupPlayer(_ file: CIFile?) { let newUrl = getLoadedVideo(file) if newUrl == url { @@ -424,6 +443,7 @@ struct CIVideoView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file, open: playReceived) Task { if let user = m.currentUser { await receiveFile(user, file.fileId, false, false) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 64fa2a4dd3..6c89a5f788 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -303,6 +303,11 @@ struct VoiceMessagePlayer: View { private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { playPauseIcon(icon, theme.colors.primary) .simultaneousGesture(TapGesture().onEnded { + chatModel.openWhenReceived(recordingFile) { file in + if let recordingSource = getLoadedFileSource(file) { + startPlayback(recordingSource) + } + } Task { if let user = chatModel.currentUser { await receiveFile(user: user, fileId: recordingFile.fileId) 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 c33a077ae9..1afea4541b 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 @@ -132,6 +132,8 @@ object BadgeModel { enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake } +data class FileToOpen(val fileId: Long, val inChatList: Boolean) + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ @@ -205,8 +207,21 @@ object ChatModel { // another in its place; only the badge alert shows regardless. Set while rendering, so not a state. var chatListBanner: ChatListBanner? = null + var fileToOpen: FileToOpen? = null + fun bannerSlotFree(banner: ChatListBanner): Boolean = chatListBanner == null || chatListBanner == banner + fun openWhenReceived(file: CIFile, inChatList: Boolean) { + fileToOpen = FileToOpen(file.fileId, inChatList) + } + + fun receivedFileToOpen(file: CIFile, inChatList: Boolean): Boolean { + val f = fileToOpen + if (f == null || f.fileId != file.fileId || f.inChatList != inChatList || !file.loaded) return false + fileToOpen = null + return !inChatList || appPlatform.isDesktop || chatId.value == null + } + // preferences val notificationPreviewMode by lazy { mutableStateOf( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index a3c2b63f8f..1523e2ff22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -80,6 +80,7 @@ fun CIFileView( if (prohibited != null) { showProhibitedFileAlert(file, prohibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } } @@ -188,16 +189,18 @@ fun CIFileView( val encrypted = file.fileSource.cryptoArgs != null SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() }) } + val onClick: () -> Unit = { + if (appPlatform.isAndroid && loadedFilePath != null) { + showOpenSaveMenu.value = true + } else { + fileAction() + } + } + if (file != null) OpenWhenReceivedEffect(file, smallView, onClick) Row( Modifier .combinedClickable( - onClick = { - if (appPlatform.isAndroid && loadedFilePath != null) { - showOpenSaveMenu.value = true - } else { - fileAction() - } - }, + onClick = onClick, onLongClick = { showMenu.value = true } ) .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), @@ -239,6 +242,13 @@ fun CIFileView( // the core decides whether a received file is above the size the sender's badge allows fun fileSizeValid(file: CIFile): Boolean = file.fileProhibited == null +@Composable +fun OpenWhenReceivedEffect(file: CIFile, inChatList: Boolean, open: () -> Unit) { + KeyChangeEffect(file.loaded) { + if (chatModel.receivedFileToOpen(file, inChatList)) open() + } +} + fun showProhibitedFileAlert(file: CIFile, prohibited: FileProhibited) { val badgeIssue = when (prohibited.badgeStatus) { null, BadgeStatus.Active -> "" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index d5ea44126b..a49884beeb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -173,6 +173,15 @@ fun CIImageView( return null } + val view = LocalMultiplatformView() + if (file != null) { + OpenWhenReceivedEffect(file, smallView) { + hideKeyboard(view) + ModalManager.fullscreen.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) + } + } + } Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) .then( @@ -222,6 +231,7 @@ fun CIImageView( if (file.fileProhibited != null) { showProhibitedFileAlert(file, file.fileProhibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } file.fileStatus is CIFileStatus.RcvAccepted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 21f406db18..62e1b9f737 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -70,6 +70,8 @@ fun CIVideoView( } } val f = filePath.value + val autoPlay = remember { mutableStateOf(false) } + if (file != null) OpenWhenReceivedEffect(file, smallView) { autoPlay.value = true } if (file != null && f != null) { val view = LocalMultiplatformView() val openFullscreen = { @@ -80,7 +82,6 @@ fun CIVideoView( } val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) } - val autoPlay = remember { mutableStateOf(false) } val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) } val decrypted = uriDecrypted.value if (decrypted != null && smallView) { @@ -98,7 +99,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, smallView, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -128,7 +129,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { - PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, smallView, receiveFile) } } } } @@ -156,22 +157,26 @@ private fun VideoViewEncrypted( ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } + val play: () -> Unit = { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + LaunchedEffect(autoPlay.value) { + if (autoPlay.value) play() + } Box { VideoPreviewImageView(defaultPreview, smallView = false, blurred = blurred, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(1f, onLongClick = onLongClick) } else if (!blurred.value) { - PlayButton(false, 1f, onLongClick = onLongClick) { - decryptionInProgress = true - withBGApi { - try { - uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() - autoPlay.value = uriUnencrypted.value != null - } finally { - decryptionInProgress = false - } - } - } + PlayButton(false, 1f, onLongClick = onLongClick, onClick = play) } DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(defaultDuration) }, remember { mutableStateOf(0L) }) } @@ -190,22 +195,26 @@ private fun SmallVideoViewEncrypted( ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } + val play: () -> Unit = { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + LaunchedEffect(autoPlay.value) { + if (autoPlay.value) play() + } Box { VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick) } else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) { - PlayButton(false, sizeMultiplier, onLongClick = onLongClick) { - decryptionInProgress = true - withBGApi { - try { - uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() - autoPlay.value = uriUnencrypted.value != null - } finally { - decryptionInProgress = false - } - } - } + PlayButton(false, sizeMultiplier, onLongClick = onLongClick, onClick = play) } } } @@ -240,7 +249,7 @@ private fun SmallVideoView( PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen) } } - LaunchedEffect(uri) { + LaunchedEffect(uri, autoPlay.value) { if (autoPlay.value) openFullscreen() } } @@ -272,7 +281,7 @@ private fun VideoView( player.stop() } val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } } - LaunchedEffect(uri) { + LaunchedEffect(uri, autoPlay.value) { if (autoPlay.value) play() } // Drop autoPlay only when show preview changes to prevent blinking of the view @@ -566,10 +575,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { +private fun receiveFileIfValidSize(file: CIFile, smallView: Boolean, receiveFile: (Long) -> Unit) { if (file.fileProhibited != null) { showProhibitedFileAlert(file, file.fileProhibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 3b3c835e0a..5aeefd9668 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -87,7 +87,7 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, receiveFile) { + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, { chatModel.openWhenReceived(file, smallView); receiveFile(it) }) { AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } if (smallView) { @@ -95,6 +95,7 @@ fun CIVoiceView( AudioPlayer.stop() } } + OpenWhenReceivedEffect(file, smallView, play) } else if (smallView) { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, sizeMultiplier, {}, {}, longClick, receiveFile) } else { From 2f4a443dda2600278446fd082f73275de2f13ad1 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 13 Oct 2026 14:38:37 +0100 Subject: [PATCH 19/31] ui: markdown viewer (#7702) * ui: markdown viewer * simplify * fixes * plan * ios sheet, center modal on desktop --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Views/Chat/ChatItem/CIFileView.swift | 14 +- .../Chat/ChatItem/MarkdownFileView.swift | 215 ++++++++++++ .../NetworkAndServers/ConditionsWebView.swift | 37 +- .../Views/UserSettings/SettingsView.swift | 2 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/spec/client/chat-view.md | 6 + apps/multiplatform/common/build.gradle.kts | 4 +- .../simplex/common/platform/Images.android.kt | 9 + .../common/views/helpers/Utils.android.kt | 3 +- .../kotlin/chat/simplex/common/App.kt | 5 +- .../chat/simplex/common/model/SimpleXAPI.kt | 2 + .../chat/simplex/common/platform/Images.kt | 1 + .../common/views/chat/item/CIFileView.kt | 4 +- .../views/chat/item/MarkdownFileView.kt | 284 ++++++++++++++++ .../views/chatlist/ChatListNavLinkView.kt | 2 +- .../simplex/common/views/helpers/ModalView.kt | 5 +- .../networkAndServers/OperatorView.kt | 33 +- .../moko-resources/base/strings.xml | 3 + .../simplex/common/platform/Images.desktop.kt | 9 + .../views/chat/item/CIImageView.desktop.kt | 2 +- apps/multiplatform/product/README.md | 4 +- apps/multiplatform/spec/client/chat-view.md | 1 + apps/multiplatform/spec/client/navigation.md | 6 +- plans/2026-10-11-markdown-file-viewer.md | 319 ++++++++++++++++++ 24 files changed, 941 insertions(+), 33 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt create mode 100644 plans/2026-10-11-markdown-file-viewer.md diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 59ab1c24e0..5c65a310b5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -118,7 +118,7 @@ struct CIFileView: View { case .rcvComplete: logger.debug("CIFileView fileAction - in .rcvComplete") if let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case let .rcvError(rcvFileError): logger.debug("CIFileView fileAction - in .rcvError") @@ -129,12 +129,12 @@ struct CIFileView: View { case .sndStored: logger.debug("CIFileView fileAction - in .sndStored") if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case .sndComplete: logger.debug("CIFileView fileAction - in .sndComplete") if let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case let .sndError(sndFileError): logger.debug("CIFileView fileAction - in .sndError") @@ -147,6 +147,14 @@ struct CIFileView: View { } } + private func openFile(_ file: CIFile, _ fileSource: CryptoFile) { + if isMarkdownFile(file) { + showMarkdownFile(file) + } else { + saveCryptoFile(fileSource) + } + } + @ViewBuilder private func fileIndicator() -> some View { if let file = file { switch file.fileStatus { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift new file mode 100644 index 0000000000..aa7e827d6b --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift @@ -0,0 +1,215 @@ +import SwiftUI +import SimpleXChat +import Ink + +private let MAX_MARKDOWN_VIEW_SIZE: Int64 = 1_048_576 +private let MAX_MARKDOWN_LINE_LENGTH = 15_000 +private let MAX_MARKDOWN_NESTING = 16 +private let MAX_MARKDOWN_LINK_NESTING = 4 +private let MAX_MARKDOWN_TABLE_ROWS = 200 +private let MAX_MARKDOWN_IMAGE_DIMENSION: CGFloat = 4320 +private let MAX_MARKDOWN_IMAGE_PIXELS: CGFloat = 2160 * 4320 + +func isMarkdownFile(_ file: CIFile) -> Bool { + let ext = (file.fileName as NSString).pathExtension.lowercased() + return file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && (ext == "md" || ext == "markdown") +} + +func showMarkdownFile(_ file: CIFile) { + guard let fileSource = getLoadedFileSource(file) else { return } + Task { + do { + let data = try getFileData(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs) + let text = decodedText(data) + let lines = text.components(separatedBy: "\n") + let html = withinMarkdownLimits(lines) + ? MarkdownParser(modifiers: [Modifier(target: .html) { escapedHTML($0.markdown) }, imagesWithinBudget()]).html(from: text) + : nil + let shownLines = lines.flatMap(chunkedLine) + let hasBidiControls = text.unicodeScalars.contains(where: isBidiControl) + await MainActor.run { + showAppSheet { + MarkdownFileView(file: file, fileSource: fileSource, lines: shownLines, html: html, hasBidiControls: hasBidiControls) + } + } + } catch { + await MainActor.run { + showAlert(NSLocalizedString("Error reading file", comment: "alert title"), message: error.localizedDescription) + } + } + } +} + +private func decodedText(_ data: Data) -> String { + var text = String.UnicodeScalarView() + var parser = Unicode.UTF8.ForwardParser() + var bytes = data.makeIterator() + while true { + switch parser.parseScalar(from: &bytes) { + case let .valid(encoded): text.append(replacingControlChar(Unicode.UTF8.decode(encoded))) + case let .error(length): text.append(contentsOf: repeatElement("?" as Unicode.Scalar, count: length)) + case .emptyInput: return String(text).replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") + } + } +} + +private func replacingControlChar(_ s: Unicode.Scalar) -> Unicode.Scalar { + s.value < 0x20 && s != "\t" && s != "\n" && s != "\r" ? "?" : s +} + +private func withinMarkdownLimits(_ lines: [String]) -> Bool { + var tableRows = 0 + var linkNesting = 0 + for line in lines { + tableRows = line.contains("|") ? tableRows + 1 : 0 + if line.count > MAX_MARKDOWN_LINE_LENGTH || nestingLevel(line) > MAX_MARKDOWN_NESTING || tableRows > MAX_MARKDOWN_TABLE_ROWS || !linkNestingWithinLimit(line, &linkNesting) { + return false + } + } + return true +} + +private func linkNestingWithinLimit(_ line: String, _ nesting: inout Int) -> Bool { + if line.isEmpty { nesting = 0 } + for c in line { + if c == "[" { + nesting += 1 + if nesting > MAX_MARKDOWN_LINK_NESTING { return false } + } else if c == "]" && nesting > 0 { + nesting -= 1 + } + } + return true +} + +private func nestingLevel(_ line: String) -> Int { + let content = line.drop(while: { $0 == " " || $0 == "\t" || $0 == ">" }) + let prefix = line[.." }.count + let listItem = content.first.map { "-*+".contains($0) || $0.isNumber } ?? false + return quotes + (listItem ? (prefix.count - quotes) / 2 : 0) +} + +private func chunkedLine(_ line: String) -> [String] { + let chars = Array(line) + return stride(from: 0, to: max(chars.count, 1), by: MAX_MARKDOWN_LINE_LENGTH).map { + String(chars[$0 ..< min($0 + MAX_MARKDOWN_LINE_LENGTH, chars.count)]) + } +} + +private func escapedHTML(_ s: Substring) -> String { + s.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") +} + +private func imagesWithinBudget() -> Modifier { + var pixels = MAX_MARKDOWN_IMAGE_PIXELS + return Modifier(target: .images) { image in + let src = image.html.dropFirst("" + } +} + +private func isBidiControl(_ s: Unicode.Scalar) -> Bool { + [0x061C, 0x200E, 0x200F].contains(s.value) || (0x202A...0x202E).contains(s.value) || (0x2066...0x2069).contains(s.value) +} + +private func withInvisibleCharMarkers(_ line: String) -> Text { + var text = Text(verbatim: "") + var plain = "" + for s in line.unicodeScalars { + if s.properties.generalCategory == .format { + text = text + Text(verbatim: plain) + Text(verbatim: unicodeMarker(s)).foregroundColor(.orange) + plain = "" + } else { + plain.unicodeScalars.append(s) + } + } + return text + Text(verbatim: plain) +} + +private func openMarkdownFileLink(_ url: URL) { + let uri = url.absoluteString + let parsed = parseSimpleXMarkdown(uri) + let format = parsed?.count == 1 ? parsed?.first?.format : nil + switch format { + case let .simplexLink(_, _, simplexUri, _)?: + if let simplexUrl = URL(string: simplexUri) { + ChatModel.shared.appOpenUrl = simplexUrl + } else { + showInvalidLinkAlert(uri) + } + case .uri? where uri.hasPrefix("https://") || uri.hasPrefix("http://"): + openBrowserAlert(uri: uri) + default: + showInvalidLinkAlert(uri) + } +} + +struct MarkdownFileView: View { + @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW) private var textView = false + let file: CIFile + let fileSource: CryptoFile + let lines: [String] + let html: String? + let hasBidiControls: Bool + + var body: some View { + NavigationView { + VStack(spacing: 0) { + if hasBidiControls { + Text("This file has text direction characters. The text can be shown in a different order than it is written.") + .foregroundColor(.orange) + .padding() + } + if let html, !textView { + ConditionsWebView(html: html, openLink: openMarkdownFileLink) + .padding(.horizontal) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(lines.indices, id: \.self) { i in + withInvisibleCharMarkers(lines[i]) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding() + } + } + } + .modifier(ThemedBackground()) + .navigationTitle(file.fileName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if html != nil { + Button { + textView.toggle() + } label: { + Image(systemName: textView ? "doc.richtext" : "chevron.left.forwardslash.chevron.right") + } + } + Button { + saveCryptoFile(fileSource) + } label: { + Image(systemName: "square.and.arrow.up") + } + } + } + } + .navigationViewStyle(.stack) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift index 5abbbf8d2e..6a05851c63 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift @@ -12,11 +12,15 @@ import WebKit struct ConditionsWebView: UIViewRepresentable { @State var html: String + var openLink: (URL) -> Void = openExternalLink @EnvironmentObject var theme: AppTheme @State var pageLoaded = false func makeUIView(context: Context) -> WKWebView { - let view = WKWebView() + let config = WKWebViewConfiguration() + config.defaultWebpagePreferences.allowsContentJavaScript = false + config.websiteDataStore = .nonPersistent() + let view = WKWebView(frame: .zero, configuration: config) view.backgroundColor = .clear view.isOpaque = false view.navigationDelegate = context.coordinator @@ -35,8 +39,9 @@ struct ConditionsWebView: UIViewRepresentable { } private func loadPage(_ webView: WKWebView) { + let nonce = UUID().uuidString let styles = """ - """ - let head = "\(styles)" + let csp = "" + let head = "\(csp)\(styles)" webView.loadHTMLString(head + html, baseURL: nil) DispatchQueue.main.async { pageLoaded = true @@ -58,10 +77,16 @@ struct ConditionsWebView: UIViewRepresentable { } func makeCoordinator() -> Cordinator { - Cordinator() + Cordinator(openLink: openLink) } class Cordinator: NSObject, WKNavigationDelegate { + let openLink: (URL) -> Void + + init(openLink: @escaping (URL) -> Void) { + self.openLink = openLink + } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { @@ -71,9 +96,9 @@ struct ConditionsWebView: UIViewRepresentable { switch navigationAction.navigationType { case .linkActivated: decisionHandler(.cancel) - openExternalLink(url) + openLink(url) default: - decisionHandler(.allow) + decisionHandler(url.scheme == "about" ? .allow : .cancel) } } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 6cf9b5abe6..f7042b0c39 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -84,6 +84,7 @@ let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice" let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice" let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage" +let DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView" let DEFAULT_CURRENT_THEME = "currentTheme" let DEFAULT_SYSTEM_DARK_THEME = "systemDarkTheme" @@ -145,6 +146,7 @@ let appDefaults: [String: Any] = [ DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true, DEFAULT_SHOW_SENT_VIA_RPOXY: false, DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false, + DEFAULT_MARKDOWN_FILE_TEXT_VIEW: false, ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue, DEFAULT_THEME_OVERRIDES: "{}", diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a138b24e4e..e9cfcab5bc 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -168,6 +168,7 @@ 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; + 6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; }; 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; }; @@ -560,6 +561,7 @@ 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; + 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; @@ -1123,6 +1125,7 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, 5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */, 648010AA281ADD15009009B9 /* CIFileView.swift */, + 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */, 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */, 3CDBCF4727FF621E00354CDD /* CILinkView.swift */, 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */, @@ -1600,6 +1603,7 @@ 5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */, 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, + 6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */, 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */, 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index c509662c9e..b63d336c34 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -238,6 +238,12 @@ Renders voice messages with waveform visualization, play/pause control, and dura Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download. +### MarkdownFileView + +**File**: [`Shared/Views/Chat/ChatItem/MarkdownFileView.swift`](../../Shared/Views/Chat/ChatItem/MarkdownFileView.swift) + +Sheet with a viewer of Markdown files up to `MAX_MARKDOWN_VIEW_SIZE` (1 MB), formatted or as text, with Save. Opened by a tap on the file item, or when reception completes after the tap that started it. + ### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) **File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 0ff63fa8de..e9503bc6ce 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -77,8 +77,8 @@ kotlin { api("dev.icerock.moko:resources-compose:0.27.1") // Markdown - implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0") - implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.27.0") + implementation("com.mikepenz:multiplatform-markdown-renderer:0.34.0") + implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.34.0") } } val commonTest by getting { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt index 1a3703822d..df2e6ccadc 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -13,6 +13,7 @@ import androidx.core.graphics.scale import boofcv.android.ConvertBitmap import boofcv.struct.image.GrayU8 import chat.simplex.common.R +import chat.simplex.common.views.helpers.decodeSampledBitmapFromByteArray import chat.simplex.common.views.helpers.errorBitmap import chat.simplex.common.views.helpers.getFileName import java.io.ByteArrayOutputStream @@ -41,6 +42,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { } } +actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? = + try { + decodeSampledBitmapFromByteArray(Base64.decode(base64, Base64.DEFAULT), width, width, MAX_IMAGE_DIMENSION).asImageBitmap() + } catch (e: Exception) { + Log.e(TAG, "decodeBase64Image error: $e") + null + } + actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { var img = image var str = compressImageStr(img) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 371bd7676b..7b2f53d365 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -194,11 +194,12 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair? } // https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap -private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap { +internal fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int, maxDimension: Int = Int.MAX_VALUE): Bitmap { // First decode with inJustDecodeBounds=true to check dimensions return BitmapFactory.Options().run { inJustDecodeBounds = true BitmapFactory.decodeByteArray(data, 0, data.size, this) + if (outWidth > maxDimension || outHeight > maxDimension) throw IOException("Image dimensions exceed limit") // Calculate inSampleSize inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) // Decode bitmap with inSampleSize set 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 656911443a..ce1152de6a 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 @@ -415,7 +415,10 @@ fun CenterPartOfScreen() { } } } - else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} + else -> { + ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} + ModalManager.center.showInView() + } } } 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 14bb9304d8..faf27d3ceb 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 @@ -142,6 +142,7 @@ class AppPreferences { val appearanceBarsBlurRadius = mkIntPreference(SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS, if (deviceSupportsBlur) 50 else 0) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false) + val markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false) val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null) val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) @@ -429,6 +430,7 @@ class AppPreferences { const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites" + private const val SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW = "MarkdownFileTextView" private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName" private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage" 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 d7a241d5ee..47cc034bfb 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 @@ -15,6 +15,7 @@ import java.io.InputStream import java.net.URI expect fun base64ToBitmap(base64ImageString: String): ImageBitmap +expect fun decodeBase64Image(base64: String, width: Int): ImageBitmap? expect fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String expect fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream expect fun cropToSquare(image: ImageBitmap): ImageBitmap diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 1523e2ff22..42cbff3a2f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -106,6 +106,8 @@ fun CIFileView( showFileErrorAlert(file.fileStatus.sndFileError) file.fileStatus is CIFileStatus.SndWarning -> showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) + isMarkdownFile(file) && file.loaded -> + showMarkdownFile(file) file.forwardingAllowed() -> { withLongRunningApi(slow = 600_000) { var filePath = getLoadedFilePath(file) @@ -184,7 +186,7 @@ fun CIFileView( val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) } val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() } - val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null + val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null && !isMarkdownFile(file)) getLoadedFilePath(file) else null if (loadedFilePath != null && file?.fileSource != null) { val encrypted = file.fileSource.cryptoArgs != null SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() }) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt new file mode 100644 index 0000000000..189e69a925 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt @@ -0,0 +1,284 @@ +package chat.simplex.common.views.chat.item + +import SectionBottomSpacer +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.networkAndServers.documentMarkdownStyle +import chat.simplex.res.* +import com.mikepenz.markdown.compose.MarkdownElement +import com.mikepenz.markdown.m2.Markdown +import com.mikepenz.markdown.model.ImageData +import com.mikepenz.markdown.model.ImageTransformer +import com.mikepenz.markdown.model.State +import com.mikepenz.markdown.model.markdownAnnotator +import com.mikepenz.markdown.model.parseMarkdownFlow +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import java.io.ByteArrayInputStream +import java.io.File +import java.net.URI +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction + +private const val DATA_IMAGE = "data:image/" +private const val MAX_MARKDOWN_VIEW_SIZE: Long = 1_048_576 +private const val MAX_MARKDOWN_LINE_LENGTH = 15_000 +private const val MAX_MARKDOWN_DEPTH = 32 +private const val MAX_MARKDOWN_TABLE_ROWS = 200 +private const val MAX_MARKDOWN_IMAGE_PIXELS = 2160L * 4320 + +private val controlChar = Regex("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]") + +private val bidiControlChar = Regex("[\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]") + +private val invisibleCharMarkerStyle = SpanStyle(color = WarningOrange) + +private class MarkdownDocument(val lines: List, val formatted: State.Success?, val hasBidiControls: Boolean) + +fun isMarkdownFile(file: CIFile): Boolean = + file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && file.fileName.substringAfterLast('.', "").lowercase() in listOf("md", "markdown") + +fun showMarkdownFile(file: CIFile) { + withLongRunningApi(slow = 600_000) { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file.loadRemoteFile(true) + filePath = getLoadedFilePath(file) + } + if (filePath == null) { + AlertManager.shared.showAlertMsg(MR.strings.file_not_found) + return@withLongRunningApi + } + val bytes = try { + val cryptoArgs = file.fileSource?.cryptoArgs + if (cryptoArgs != null) readCryptoFile(filePath, cryptoArgs) else File(filePath).readBytes() + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.message) + return@withLongRunningApi + } + val text = decodedText(bytes) + val lines = text.lines() + val formatted = if (lines.all { it.length <= MAX_MARKDOWN_LINE_LENGTH }) formattedWithinLimits(text) else null + val document = MarkdownDocument( + lines.flatMap { line -> line.chunked(MAX_MARKDOWN_LINE_LENGTH).ifEmpty { listOf("") } }, + formatted, + bidiControlChar.containsMatchIn(text) + ) + ModalManager.center.showModal(endButtons = { + if (formatted != null) MarkdownViewSwitch() + MarkdownFileSaveButton(file, bytes) + }) { + MarkdownFileView(file.fileName, document) + } + } +} + +private fun decodedText(bytes: ByteArray): String = + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + .replaceWith("?") + .decode(ByteBuffer.wrap(bytes)) + .toString() + .replace(controlChar, "?") + +private suspend fun formattedWithinLimits(text: String): State.Success? = + (parseMarkdownFlow(text).first { it !is State.Loading } as? State.Success)?.takeIf { treeWithinLimits(it.node) } + +private fun treeWithinLimits(root: ASTNode): Boolean { + val nodes = ArrayDeque(listOf(root to 1)) + while (nodes.isNotEmpty()) { + val (node, depth) = nodes.removeLast() + if (depth > MAX_MARKDOWN_DEPTH) return false + if (node.type == GFMElementTypes.TABLE && node.children.count { it.type == GFMElementTypes.ROW } > MAX_MARKDOWN_TABLE_ROWS) return false + node.children.forEach { nodes.addLast(it to depth + 1) } + } + return true +} + +@Composable +private fun MarkdownViewSwitch() { + val textView = remember { appPrefs.markdownFileTextView.state } + IconButton({ appPrefs.markdownFileTextView.set(!textView.value) }) { + Icon( + painterResource(if (textView.value) MR.images.ic_article else MR.images.ic_code), + stringResource(if (textView.value) MR.strings.markdown_formatted_view else MR.strings.markdown_text_view), + tint = MaterialTheme.colors.primary + ) + } +} + +@Composable +private fun MarkdownFileSaveButton(file: CIFile, bytes: ByteArray) { + val saveFileLauncher = rememberFileChooserLauncher(false, bytes) { to: URI? -> + if (to != null) copyBytesToFile(ByteArrayInputStream(bytes), to) {} + } + IconButton({ withLongRunningApi { saveFileLauncher.launch(file.fileName) } }) { + Icon(painterResource(MR.images.ic_download), stringResource(MR.strings.save_verb), tint = MaterialTheme.colors.primary) + } +} + +@Composable +private fun MarkdownFileView(fileName: String, document: MarkdownDocument) { + val textView = remember { appPrefs.markdownFileTextView.state } + val formatted = document.formatted + if (textView.value || formatted == null) { + SelectionContainer { + MarkdownFileColumn(fileName, document.hasBidiControls) { + items(document.lines) { line -> + Text(remember(line) { withInvisibleCharMarkers(line) }, Modifier.padding(horizontal = DEFAULT_PADDING), fontFamily = FontFamily.Monospace) + } + } + } + } else { + FormattedMarkdown(fileName, formatted, document.hasBidiControls) + } +} + +private fun withInvisibleCharMarkers(line: String): AnnotatedString = buildAnnotatedString { + var start = 0 + invisibleChar.findAll(line).forEach { + append(line.substring(start, it.range.first)) + withStyle(invisibleCharMarkerStyle) { append(unicodeMarker(it.value)) } + start = it.range.last + 1 + } + append(line.substring(start)) +} + +@Composable +private fun FormattedMarkdown(fileName: String, formatted: State.Success, hasBidiControls: Boolean) { + val style = documentMarkdownStyle(h1 = MaterialTheme.typography.h2.copy(fontSize = 26.sp, fontWeight = FontWeight.Bold)) + val codeSpanStyle = style.typography.inlineCode.toSpanStyle().copy(background = style.colors.inlineCodeBackground) + val annotator = remember(codeSpanStyle) { + markdownAnnotator { content, child -> + when (child.type) { + MarkdownElementTypes.CODE_SPAN -> { + withStyle(codeSpanStyle) { append(" ${child.getTextInNode(content).trim('`')} ") } + true + } + MarkdownElementTypes.IMAGE -> + if (imageLink(content, child)?.startsWith(DATA_IMAGE) == true) { + false + } else { + append(child.getTextInNode(content).toString()) + true + } + else -> false + } + } + } + val imageTransformer = remember { DataImageTransformer() } + val parentUriHandler = LocalUriHandler.current + CompositionLocalProvider(LocalUriHandler provides remember { markdownFileUriHandler(parentUriHandler) }) { + Markdown( + formatted, + colors = style.colors, + typography = style.typography, + padding = style.padding, + imageTransformer = imageTransformer, + annotator = annotator, + components = style.components, + success = { state, components, _ -> + MarkdownFileColumn(fileName, hasBidiControls) { + items(state.node.children) { node -> + Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + MarkdownElement(node, components, state.content, skipLinkDefinition = state.linksLookedUp) + } + } + } + } + ) + } +} + +@Composable +private fun MarkdownFileColumn(fileName: String, hasBidiControls: Boolean, content: LazyListScope.() -> Unit) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + LazyColumnWithScrollBar( + contentPadding = PaddingValues( + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp else topPaddingToContent(false) + ) + ) { + item { AppBarTitle(fileName) } + if (hasBidiControls) { + item { + Text(stringResource(MR.strings.markdown_file_bidi_warning), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING), color = WarningOrange) + } + } + content() + item { SectionBottomSpacer() } + } +} + +private fun imageLink(content: String, image: ASTNode): String? = + image.children.firstOrNull { it.type == MarkdownElementTypes.INLINE_LINK } + ?.children?.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION } + ?.getTextInNode(content)?.toString() + +private fun markdownFileUriHandler(parentUriHandler: UriHandler): UriHandler = object: UriHandler { + override fun openUri(uri: String) { + val format = parseToMarkdown(uri)?.singleOrNull()?.format + when { + format is Format.SimplexLink -> parentUriHandler.openVerifiedSimplexUri(format.simplexUri) + format is Format.Uri && (uri.startsWith("https://") || uri.startsWith("http://")) -> openBrowserAlert(uri, parentUriHandler) + else -> showInvalidLinkAlert(uri) + } + } +} + +private class DataImageTransformer : ImageTransformer { + private var pixels = MAX_MARKDOWN_IMAGE_PIXELS + private val images = mutableMapOf() + + @Composable + override fun transform(link: String): ImageData? { + val width = with(LocalDensity.current) { LocalWindowWidth().roundToPx() } + val bitmap by produceState(null, link) { + value = withContext(Dispatchers.Default) { decodedImage(link, width) } + } + return remember(bitmap) { bitmap?.let { ImageData(BitmapPainter(it)) } } + } + + private fun decodedImage(link: String, width: Int): ImageBitmap? = synchronized(this) { + if (link !in images) images[link] = imageWithinBudget(link, width) + images[link] + } + + private fun imageWithinBudget(link: String, width: Int): ImageBitmap? = + link.takeIf { it.startsWith(DATA_IMAGE) } + ?.let { decodeBase64Image(it.substringAfter("base64,"), width) } + ?.takeIf { it.width.toLong() * it.height <= pixels } + ?.also { pixels -= it.width.toLong() * it.height } +} 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 489ebb8c39..32e0befa3f 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 @@ -586,7 +586,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection onClick = { ModalManager.center.closeModals() ModalManager.end.closeModals() - ModalManager.center.showModalCloseable(settings = true, showClose = appPlatform.isAndroid, cardScreen = true) { close -> + ModalManager.center.showModalCloseable(settings = true, cardScreen = true) { close -> ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, true, close) } showMenu.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 02c0b45de4..b13c321f70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import LocalCardScreen import chat.simplex.common.ui.theme.* @@ -142,9 +141,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { modalViews.add(ModalViewHolder(id, anim, data, modal)) _modalCount.value = modalViews.size - toRemove.size - if (placement == ModalPlacement.CENTER) { - ChatModel.chatId.value = null - } else if (placement == ModalPlacement.END) { + if (placement == ModalPlacement.END) { desktopExpandWindowToWidth(DEFAULT_START_MODAL_WIDTH * sqrt(appPrefs.fontScale.get()) + DEFAULT_MIN_CENTER_MODAL_WIDTH + DEFAULT_END_MODAL_WIDTH * sqrt(appPrefs.fontScale.get())) } } 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 1e46091ea3..20a182551c 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 @@ -33,10 +33,14 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* import chat.simplex.res.* import com.mikepenz.markdown.compose.Markdown +import com.mikepenz.markdown.compose.components.MarkdownComponents import com.mikepenz.markdown.compose.components.markdownComponents import com.mikepenz.markdown.compose.elements.MarkdownHeader import com.mikepenz.markdown.m2.markdownColor import com.mikepenz.markdown.m2.markdownTypography +import com.mikepenz.markdown.model.MarkdownColors +import com.mikepenz.markdown.model.MarkdownPadding +import com.mikepenz.markdown.model.MarkdownTypography import com.mikepenz.markdown.model.markdownPadding import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -771,22 +775,36 @@ expect fun ConditionsBox(modifier: Modifier, scrollState: ScrollState, content: @Composable private fun ConditionsMarkdown(text: String) { + val style = documentMarkdownStyle() Markdown(text, - markdownColor(linkText = MaterialTheme.colors.primary), + style.colors, + style.typography, + Modifier.padding(8.dp), + // using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly + flavour = CommonMarkFlavourDescriptor(), + components = style.components, + padding = style.padding + ) +} + +class DocumentMarkdownStyle(val colors: MarkdownColors, val typography: MarkdownTypography, val components: MarkdownComponents, val padding: MarkdownPadding) + +@Composable +fun documentMarkdownStyle(h1: TextStyle = MaterialTheme.typography.body1): DocumentMarkdownStyle = + DocumentMarkdownStyle( + markdownColor(), markdownTypography( - h1 = MaterialTheme.typography.body1, + h1 = h1, h2 = MaterialTheme.typography.h3.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold), h3 = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), h4 = MaterialTheme.typography.h5.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold), h5 = MaterialTheme.typography.h6.copy(fontWeight = FontWeight.Bold), link = MaterialTheme.typography.body1.copy( + color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline ) ), - Modifier.padding(8.dp), - // using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly - flavour = CommonMarkFlavourDescriptor(), - components = markdownComponents( + markdownComponents( heading2 = { Spacer(Modifier.height(10.dp)) MarkdownHeader(it.content, it.node, it.typography.h2) @@ -803,9 +821,8 @@ private fun ConditionsMarkdown(text: String) { Spacer(Modifier.height(4.dp)) }, ), - padding = markdownPadding(block = 4.dp) + markdownPadding(block = 4.dp) ) -} @Composable private fun ConditionsLinkView(conditionsLink: String) { diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 93bf4f5838..326be2b05d 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -687,6 +687,9 @@ File saved File not found Error saving file + Formatted view + Text view + This file has text direction characters. The text can be shown in a different order than it is written. Loading the file Please, wait while the file is being loaded from the linked mobile File error diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index 9c6139b493..925698154e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import boofcv.io.image.ConvertBufferedImage import boofcv.struct.image.GrayU8 +import chat.simplex.common.views.helpers.decodeBoundedBufferedImage import chat.simplex.res.* import org.jetbrains.skia.Image import java.awt.RenderingHints @@ -54,6 +55,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { } } +actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? = + try { + decodeBoundedBufferedImage(Base64.getMimeDecoder().decode(base64), width).toComposeImageBitmap() + } catch (e: Throwable) { + Log.e(TAG, "decodeBase64Image error: $e") + null + } + actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { var img = image var str = compressImageStr(img) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 98ffa7c8a4..dd76e86c81 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -23,7 +23,7 @@ actual fun SimpleAndAnimatedImageView( // still image. A full screen modal is shown beside the chat rather than in place of it, so this item keeps // composing under one and would otherwise decode where nobody can see it. val frame = if (smallView) imageBitmap - else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() } + else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() || ModalManager.center.hasModalsOpen() } ImageView(BitmapPainter(frame)) { if (getLoadedFilePath(file) != null) { ModalManager.fullscreen.showCustomModal(animated = false) { close -> diff --git a/apps/multiplatform/product/README.md b/apps/multiplatform/product/README.md index 5dc606a50e..3dff974366 100644 --- a/apps/multiplatform/product/README.md +++ b/apps/multiplatform/product/README.md @@ -307,7 +307,7 @@ UserPicker | | OR (when no chat selected): | GroupProfileView | | UserPicker (overlay) | "No selected chat" | AddGroupMembersView | | - profile switcher | | MemberAdmission | -| - quick settings | OR (when modal open): | VerifyCodeView | +| - quick settings | Over them (when modal open): | VerifyCodeView | | | ModalManager.center content | SettingsView subtabs | | ModalManager.start | (settings, new chat, etc.) | | | - secondary modals | | ModalManager.end | @@ -316,7 +316,7 @@ UserPicker ModalManager Placement (Desktop): - ModalManager.start -> left panel overlay (settings subviews) - - ModalManager.center -> center panel (replaces chat, used when chatId is null) + - ModalManager.center -> center panel (drawn over the chat or the placeholder) - ModalManager.end -> right panel (detail/info views) - ModalManager.fullscreen -> full window overlay (onboarding, auth, call) diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index adf8ad0224..3bad2e09c4 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -313,6 +313,7 @@ Key sections: group profile, group link, member list with roles, group preferenc | `EmojiItemView.kt` | Large emoji-only message | | `FramedItemView.kt` | Message bubble frame (quoted item, text, media) | | `ImageFullScreenView.kt` | Fullscreen image gallery | +| `MarkdownFileView.kt` | Markdown file viewer, formatted or as text | | `IntegrityErrorItemView.kt` | Message integrity error | | `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message | | `TextItemView.kt` | Plain text message with markdown | diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index 96dd58052c..d0340e8d23 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -178,7 +178,7 @@ fun DesktopScreen(userPickerState: MutableStateFlow) |---|---|---| | **Left** | `DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier` (fixed) | `StartPartOfScreen` (ChatListView) + `UserPicker` overlay | | **Left overlay** | Same as left column | `ModalManager.start` modals + `SwitchingUsersView` | -| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView or "no selected chat" placeholder, or `ModalManager.center`) | +| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView with `ModalManager.center` over it; without a chat: `ModalManager.center` or "no selected chat" placeholder) | | **Right** | `max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier` (flexible, 0 when empty) | `ModalManager.end` (ChatInfoView, GroupChatInfoView, ChatItemInfoView, etc.) | ### Column Separators @@ -196,7 +196,7 @@ When the UserPicker is visible or a start modal is open (but no center modal), a - When `chatId` is null and no center modals: shows "No selected chat" placeholder. - When `chatId` is null and center modals open: shows `ModalManager.center`. -- When `chatId` is set: shows `ChatView`. +- When `chatId` is set: shows `ChatView`, and `ModalManager.center` over it. - Automatically closes center modals when a chat is selected. ### StartPartOfScreen @@ -223,7 +223,7 @@ class ModalManager(private val placement: ModalPlacement?) | Zone | Android Behavior | Desktop Behavior | |---|---|---| | `start` | Shared (same as all others) | Left column overlay, slides from start | -| `center` | Shared | Center column overlay, replaces ChatView | +| `center` | Shared | Center column overlay, drawn over ChatView | | `end` | Shared | Right column, slides from end | | `fullscreen` | Shared | Fullscreen overlay | diff --git a/plans/2026-10-11-markdown-file-viewer.md b/plans/2026-10-11-markdown-file-viewer.md new file mode 100644 index 0000000000..c5152ea485 --- /dev/null +++ b/plans/2026-10-11-markdown-file-viewer.md @@ -0,0 +1,319 @@ +# Markdown file viewer: implementation plan + +Markdown files from chats are viewed in the Android, desktop and iOS apps, formatted or as text. + +## 1. Files that qualify + +By name and size only: + +``` +name file.fileName ends with .md or .markdown, case-insensitive +size file.fileSize <= MAX_MARKDOWN_VIEW_SIZE +``` + +``` +MAX_MARKDOWN_VIEW_SIZE 1_048_576 +Kotlin views/chat/item/MarkdownFileView.kt, private +iOS Shared/Views/Chat/ChatItem/MarkdownFileView.swift, private +``` + +Function, in each viewer file: + +``` +Kotlin fun isMarkdownFile(file: CIFile): Boolean +iOS func isMarkdownFile(_ file: CIFile) -> Bool +``` + +Other files, and Markdown files over the limit: tap actions unchanged. + +## 2. Tap and long press + +Tap on a qualifying file, every platform: + +``` +file status action +received or sent, file loaded viewer +desktop with a linked mobile loadRemoteFile, then viewer +RcvInvitation, RcvAborted reception started; viewer shown on completion, in the same place +other statuses as now: waiting alerts, error alerts +``` + +Long press: the existing Save item of the item menu (`ChatItemView.kt`, `ChatView.swift`). + +Places: + +``` +Kotlin CIFileView.kt, onClick and fileAction +iOS CIFileView.swift, fileAction +``` + +- Android: "Open with" of `SaveOrOpenFileMenu` is not shown for qualifying files; "Share" in the item menu stays. +- Present locally: `getLoadedFilePath(file)` (Kotlin), `getLoadedFileSource(file)` (iOS), as for other files. + +## 3. Open on completion + +In master (#7700), for images, voice messages, video and files. The viewer adds only the Markdown case to the file tap action. + +A tap that starts reception registers the file. On completion the tap action of the received file is performed: + +``` +image full screen view +voice playback +video playback; locally encrypted: decrypted first; chat list preview: full screen player +file Markdown: viewer + other: share sheet (iOS), open/save menu (Android), save dialog (desktop) +``` + +Kotlin, chat model: + +``` +fileToOpen file ID of the last tapped file + inChatList: tap in a chat list preview +openWhenReceived(file, inChatList) sets fileToOpen +receivedFileToOpen(file, inChatList) false unless fileToOpen has file.fileId and inChatList, and the file is loaded + then fileToOpen cleared + chat view: true + chat list preview: true on desktop, or when no chat is open +``` + +Item views: `OpenWhenReceivedEffect(file, inChatList, open)` (`CIFileView.kt`), on a change of `file.loaded`; in `CIImageView.kt`, `CIVoiceView.kt`, `CIVideoView.kt`, `CIFileView.kt`. + +iOS, chat model: + +``` +fileToOpen file ID of the last tapped file + chat ID at the tap; nil in the chat list + open callback of the tapped view +openWhenReceived(file, open) sets fileToOpen +receivedFileToOpen(file) fileToOpen has file.fileId, and the open chat is the chat at the tap +openReceivedFile(file) receivedFileToOpen rechecked; fileToOpen cleared; open(file) +``` + +Event `rcvFileComplete` (`SimpleXAPI.swift`): for the active user, when `receivedFileToOpen(file)`: `openReceivedFile(file)` on the main actor. + +`fileToOpen` is replaced by a later tap. + +## 4. Reading in memory + +The file is read and decoded off the main thread, before the viewer is shown. + +``` +Kotlin withLongRunningApi + encrypted: readCryptoFile(path, cryptoArgs) (CryptoFile.kt:36) + plain: File(path).readBytes() + text: UTF-8 CharsetDecoder, REPLACE with "?" + parsed: parseMarkdownFlow (renderer), once, before the viewer is shown +iOS Task + getFileData(path, cryptoArgs) (SimpleXChat/ImageUtils.swift:41) + text: Unicode.UTF8.ForwardParser, "?" for each invalid byte + html: MarkdownParser with an .html modifier, raw HTML escaped +``` + +Text decoding, as the core safeDecodeUtf8 (simplexmq Util.hs:383-386): + +``` +invalid UTF-8 "?" for each invalid byte +C0 controls U+0000-U+001F replaced with "?", except tab, line feed, carriage return +line endings CRLF and CR are line breaks: Kotlin text.lines(); iOS: replaced with LF before parsing +``` + +Limits, checked before rendering; over a limit: text view only, the switch hidden: + +``` +line length 15 000 characters +nesting Kotlin: 32 levels of the parsed tree; iOS: 16 levels estimated from line prefixes +link nesting iOS: 4 open square brackets, counted until an empty line +table rows 200 +parse error Kotlin: text view only +``` + +Text view: lines longer than 15 000 characters are split. + +- `readCryptoFile`: core `chatReadFile` (`Mobile/File.hs:87`); the file is decrypted by `CF.readFile` into memory. +- Bytes, text and HTML are held only in memory, in the viewer's state. +- Read error: alert with the error text; the viewer is not shown. +- File absent: "File not found" alert (Kotlin); no action (iOS), as for other files. + +## 5. Viewer + +``` +presentation Android: full screen, like the image viewer + desktop: over the conversation + iOS: sheet +toolbar back (Kotlin) + switch between the formatted and the text view + save +title file name +preference view chosen last; default formatted +``` + +### 5.1. Kotlin + +Renderer: `com.mikepenz:multiplatform-markdown-renderer` and `-m2`, 0.27.0 → 0.34.0 (`common/build.gradle.kts:80-81`). + +``` +0.34.0 dependencies kotlin-stdlib 2.1.20, org.jetbrains:markdown 0.7.3, kotlinx-collections-immutable 0.3.8 +compose compileOnly; built with Compose 1.8.0, the app uses 1.8.2 +app Kotlin 2.1.20, Compose Multiplatform 1.8.2 +added table component, LazyMarkdownSuccess, success lambda of Markdown() +``` + +New file `views/chat/item/MarkdownFileView.kt`: + +``` +isMarkdownFile(file) +showMarkdownFile(file) loadRemoteFile if needed, read, ModalManager.center.showModal +MarkdownFileView formatted or text view by appPrefs.markdownFileTextView +``` + +Formatted view: + +``` +renderer m2 Markdown(state), GFM flavour, the parsed state from showMarkdownFile +styles documentMarkdownStyle(h1) (OperatorView.kt), shared with the operator conditions + h1: typography h2, 26 sp, bold +lazy success lambda: LazyColumnWithScrollBar, title item, one item per top-level node: MarkdownElement in a Column +links LocalUriHandler: markdownFileUriHandler +code spans annotator: source text between the backticks; inlineCode style, inlineCodeBackground +images data:image URIs: DataImageTransformer, decoded off the main thread by decodeBase64Image + other image links: the annotator appends the source text; nothing is fetched +tables default table component +``` + +Links, classified by the core markdown parser (`parseToMarkdown`), as message links: + +``` +SimplexLink openVerifiedSimplexUri +Uri, http or https openBrowserAlert +other showInvalidLinkAlert +``` + +Displayed link targets (`displayedUri`, Kotlin and iOS; also in message link alerts and the external-link alert): + +``` +format characters (Cf) [U+XXXX] +non-ASCII in the authority [U+XXXX] +HTML not parsed (parseHtml = false) +``` + +Images: + +``` +decode decodeBase64Image(base64, window width): power-of-two subsampling, both sides kept at or above the window width + Android: decodeSampledBitmapFromByteArray, 4320 px per side + desktop: decodeBoundedBufferedImage, 16384 px per side, 4320 × 4320 decoded pixels +budget 2160 × 4320 decoded pixels per document; images over the budget are not shown +cache per document, in the transformer; the desktop base64BitmapCache is not used +``` + +Text view: `LazyColumnWithScrollBar`, title item, one monospace `Text` per line, inside `SelectionContainer`; format characters shown as `[U+XXXX]` markers. + +Bidi controls (U+061C, U+200E, U+200F, U+202A-U+202E, U+2066-U+2069): warning under the title, both views. + +Toolbar end buttons: + +``` +switch ic_code in the formatted view, ic_article in the text view; sets appPrefs.markdownFileTextView; hidden over the limits +save ic_download; rememberFileChooserLauncher; copyBytesToFile(ByteArrayInputStream(bytes)) (Files.kt:65) +``` + +`OperatorView.kt`: + +``` +markdownColor(linkText = primary) markdownColor(); link colour moved to the link text style (linkText deprecated in 0.34.0) +ConditionsMarkdown styles moved to documentMarkdownStyle(h1): DocumentMarkdownStyle (colors, typography, components, padding) + h1: body1 by default, as in the conditions +``` + +`SimpleXAPI.kt`: `markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false)`. + +Centre placement on desktop: modals are drawn over the open conversation. + +``` +ModalView.kt showCustomModal: chatId is not changed +App.kt CenterPartOfScreen: ModalManager.center.showInView() after ChatView +ChatListNavLinkView.kt "Set contact name": back button on desktop +``` + +`moko-resources/base/strings.xml`: + +``` +markdown_formatted_view Formatted view +markdown_text_view Text view +markdown_file_bidi_warning This file has text direction characters. The text can be shown in a different order than it is written. +``` + +### 5.2. iOS + +New file `Shared/Views/Chat/ChatItem/MarkdownFileView.swift`, added to the SimpleX target in `project.pbxproj`. + +``` +isMarkdownFile(file) +showMarkdownFile(file) read, parse, showAppSheet +MarkdownFileView NavigationView; toolbar: switch, save +``` + +``` +formatted ConditionsWebView(html:openLink:), Ink 0.6.0, raw HTML escaped by an .html modifier +text ScrollView, LazyVStack, one monospace Text per line, text selection, format characters as [U+XXXX] +switch @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW); hidden over the limits +save saveCryptoFile(fileSource) (CIFileView.swift) +close swipe down +links openMarkdownFileLink: parseSimpleXMarkdown; simplexLink: appOpenUrl; uri, http or https: openBrowserAlert; other: showInvalidLinkAlert +``` + +`SettingsView.swift`: `DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView"`, default false. + +`ConditionsWebView.swift`, shared with the operator conditions: + +``` +configuration allowsContentJavaScript = false; websiteDataStore = .nonPersistent() +head Content-Security-Policy: default-src 'none'; style-src 'nonce-'; img-src data:; base-uri 'none'; form-action 'none' +style the app's style block with the nonce; table, th, td borders; pre overflow-x auto; img max-width 100% +navigation linkActivated: openLink (openExternalLink by default) + other navigation: allowed for about: URLs only +``` + +- Ink 0.6.0 applies `.html` modifiers to block and inline HTML fragments (`HTMLConvertible.swift`); the viewer's modifier escapes `&`, `<`, `>`; code spans and blocks are escaped by Ink itself. + +Images, an `.images` modifier (`imagesWithinBudget`): + +``` +data:image, base64 shown as , rebuilt from the source only + 4320 px per side; 2160 × 4320 pixels per document; size read by UIImage(data:) +over a limit not shown +other data URIs not shown +other image links the source text; nothing is fetched +``` + +## 6. Size limit + +1 MB. +4 MB: formatted views render lazily (Kotlin: one lazy item per top-level node; iOS: WKWebView); text views render one lazy item per line. +A single top-level block (one table, code block or paragraph) is still laid out at once. + +## 7. Checks + +1. Plain and encrypted files: formatted and text views on Android, desktop and iOS. +2. Desktop linked to a mobile: file loaded from the mobile, then shown. +3. File over 1 MB: tap action as before. +4. File not received: one tap; viewer shown on completion in the same chat; not shown after leaving the chat. +5. Same for images, voice messages, video and other files, each with its tap action. +6. Tap in a chat list preview: opened on completion while the chat list is shown; Android, iOS: not opened after a chat is opened; desktop: opened while a chat is open. +7. Not opened after a reception error, cancel or abort. Kotlin: not opened after the item scrolled out of view. +8. Invalid UTF-8 and control characters: shown as "?". +9. Lines over 15 000 characters, 33 tree levels (Kotlin), 17 nesting levels (iOS), 5 nested square brackets (iOS), 201 table rows: text view only, long lines split. +10. Bidi controls: warning; format characters as [U+XXXX] in the text view. +11. Data images: decoded to the window width (Kotlin); images over 4320 px per side (Android, iOS) or beyond 2160 × 4320 pixels per document not shown. +12. Links: SimpleX links to the connect flow; http(s) links to the "Open web link?" alert; other schemes and relative links: "Invalid link"; targets with invisible or non-ASCII host characters shown with [U+XXXX]. +13. View switch kept after closing the viewer and after a restart. +14. Save: the file at the chosen destination equals the original. +15. Link inside backticks: shown as code. +16. Remote image: not loaded, the source text shown; data URI image: shown. +17. iOS inline HTML with ` +{% if not noEvents %} + +{% endif %} + + + Invest in SimpleX Chat @@ -18,7 +30,7 @@ eleventyExcludeFromCollections: true - + From c07d28681f41c9c52509617a58c9861f2ba82e64 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 14 Oct 2026 21:42:51 +0100 Subject: [PATCH 24/31] website: update cf links and page (#7722) * website: update cf links and page * noopener --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- ...5.8-private-message-routing-chat-themes.md | 2 +- blog/20260919-simplex-supporter-badges.md | 4 +- website/langs/ar.json | 16 ++-- website/langs/cs.json | 16 ++-- website/langs/de.json | 16 ++-- website/langs/en.json | 18 ++--- website/langs/es.json | 16 ++-- website/langs/fa.json | 16 ++-- website/langs/fi.json | 16 ++-- website/langs/fr.json | 16 ++-- website/langs/he.json | 16 ++-- website/langs/hu.json | 16 ++-- website/langs/id.json | 16 ++-- website/langs/it.json | 16 ++-- website/langs/ja.json | 16 ++-- website/langs/nl.json | 16 ++-- website/langs/pl.json | 16 ++-- website/langs/pt_BR.json | 16 ++-- website/langs/ru.json | 16 ++-- website/langs/tr.json | 16 ++-- website/langs/uk.json | 16 ++-- website/langs/zh_Hans.json | 16 ++-- website/langs/zh_Hant.json | 12 +-- .../src/_includes/blog_previews/20221108.html | 2 +- .../src/_includes/blog_previews/20221206.html | 8 +- website/src/_includes/contact_page.html | 12 +-- website/src/_includes/footer.html | 38 ++++----- website/src/_includes/hero.html | 20 ++--- website/src/_includes/layouts/token.html | 6 +- website/src/_includes/navbar.html | 12 +-- .../src/_includes/sections/join_simplex.html | 8 +- website/src/crowdfunding.html | 2 +- website/src/directory.html | 2 +- website/src/fdroid.html | 2 +- website/src/file.html | 8 +- website/src/index.html | 78 +++++++++---------- website/src/js/channel-preview.jsc | 10 +-- website/src/js/directory.jsc | 1 + website/src/js/simplex-lib.jsc | 4 +- website/src/livestream.html | 2 +- website/src/messaging.html | 2 +- 41 files changed, 277 insertions(+), 280 deletions(-) diff --git a/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md b/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md index e06f7c2084..9bd342a146 100644 --- a/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md +++ b/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md @@ -110,7 +110,7 @@ We [wrote previously](https://github.com/simplex-chat/simplexmq/blob/master/rfcs While we cannot require the operators of self-hosted and private servers to disclose any information about them (apart from which server code they use - this is the requirement of the AGPLv3 license to share this information with users connecting to the server), as we add other server operators to the app, it is important for the users to have all important information about these operators and servers location. -This server release adds server information page where all this information can be made available to the users. For example, this is the information about one of the servers preset in the app. +This server release adds server information page where all this information can be made available to the users. For example, this is the information about one of the servers preset in the app. The updated server guide also includes [the instruction](../docs/SERVER.md#) about how to host this page for your server. It is generated as a static page when the server starts. We recommend using Caddy webserver to serve it. diff --git a/blog/20260919-simplex-supporter-badges.md b/blog/20260919-simplex-supporter-badges.md index 7aa2b2e4ac..50e2ebcb4b 100644 --- a/blog/20260919-simplex-supporter-badges.md +++ b/blog/20260919-simplex-supporter-badges.md @@ -11,7 +11,7 @@ permalink: "/blog/20260919-simplex-supporter-badges.html" **Published:** Sep 19, 2026 -You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge. +You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge. ## A paid feature that cannot identify you @@ -28,7 +28,7 @@ Read more about badges in the [whitepaper](https://github.com/simplex-chat/simpl ## How to get a badge - + Buy a code on [simplex.chat/badges](https://simplex.chat/badges/), paying by card, Bitcoin or Monero, and redeem it in the app: open Settings, tap **Supporter perks**, and enter the code. The badge appears on your profile. The badge does not renew by itself, and no account is created. diff --git a/website/langs/ar.json b/website/langs/ar.json index 3f5c220f19..39d72effc2 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -82,7 +82,7 @@ "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": "تستخدم بعض الشركات المالية والتأمين رسوماً بيانية اجتماعية لتحديد أسعار الفائدة وأقساط التأمين. عادةً ما تجعل الأشخاص ذوي الدخل المنخفض يدفعون أكثر — أو كما يسمى \"علاوة الفقر\".", + "privacy-matters-overlay-card-1-p-3": "تستخدم بعض الشركات المالية والتأمين رسوماً بيانية اجتماعية لتحديد أسعار الفائدة وأقساط التأمين. عادةً ما تجعل الأشخاص ذوي الدخل المنخفض يدفعون أكثر — أو كما يسمى \"علاوة الفقر\".", "privacy-matters-overlay-card-1-p-2": "يعرف تجار التجزئة عبر الإنترنت أن الأشخاص ذوي الدخل المنخفض هم أكثر عرضة لإجراء عمليات شراء عاجلة، لذلك قد يفرضون أسعارًا أعلى أو يزيلون الخصومات.", "simplex-private-6-title": "تبديل
خارج النطاق", "simplex-private-9-title": "قوائم انتظار
أحادية الاتجاه", @@ -90,7 +90,7 @@ "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 ).", + "simplex-private-card-4-point-2": "لاستخدام SimpleX عبر تور، يُرجى تثبيت تطبيق Orbot وتمكّين وكيل SOCKS5 (أو VPN على iOS ).", "simplex-private-card-5-point-2": "يجعل الرسائل ذات الأحجام المختلفة تبدو متشابهة للخوادم ومراقبي الشبكة.", "simplex-private-card-6-point-1": "العديد من شبكات التواصل عرضة لهجمات الوسيط (MITM) من قِبل الخوادم أو موفري الشبكات.", "simplex-private-card-7-point-1": "لضمان سلامة الرسائل يتم ترقيمها بالتسلسل وتضمين تجزئة الرسالة السابقة.", @@ -103,7 +103,7 @@ "hero-overlay-card-1-p-2": "لتسليم الرسائل، بدلاً من معرّفات المُستخدم المُستخدمة من قِبل جميع الشبكات الأخرى، يستخدم 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 attack، عندما يتمكّن العملاء من إعادة بث حركة مرور البيانات وتضخيمها، مما يؤدي إلى رفض الخدمة على مستوى الشبكة. يقوم عملاء SimpleX فقط بترحيل حركة مرور البيانات من اتصال معروف ولا يمكن للمهاجم استخدامها لتضخيم حركة مرور البيانات في الشبكة بالكامل.", @@ -119,17 +119,17 @@ "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-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": "أدخل عنوان بريدك الإلكتروني", @@ -209,7 +209,7 @@ "simplex-network-2-desc": "لا تقوم خوادم الترحيل SimpleX بتخزين ملفات تعريف المستخدمين وجهات الاتصال والرسائل التي تم تسليمها، ولا تتصل ببعضها البعض، ولا يوجد دليل خوادم.", "comparison-point-1-text": "يتطلب هوية عالمية", "protocol-3-text": "بروتوكولات P2P", - "simplex-unique-overlay-card-4-p-2": "تستخدم شبكة بروتوكول مفتوح وتوفرSDK لإنشاء روبوتات دردشة، مما يسمح بتنفيذ الخدمات التي يمكن للمستخدمين التفاعل معها عبر تطبيقات SimpleX Chat — التي تتطلع حقًا لمعرفة خدمات SimpleX التي ستبنيها.", + "simplex-unique-overlay-card-4-p-2": "تستخدم شبكة بروتوكول مفتوح وتوفرSDK لإنشاء روبوتات دردشة، مما يسمح بتنفيذ الخدمات التي يمكن للمستخدمين التفاعل معها عبر تطبيقات SimpleX Chat — التي تتطلع حقًا لمعرفة خدمات SimpleX التي ستبنيها.", "guide-dropdown-1": "بداية سريعة", "guide-dropdown-2": "إرسال الرسائل", "guide-dropdown-3": "مجموعات سرية", diff --git a/website/langs/cs.json b/website/langs/cs.json index 90bad228aa..0df72eeab9 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -1,7 +1,7 @@ { "simplex-private-card-10-point-2": "Umožňuje doručovat zprávy bez identifikátoru uživatelských profilů, což poskytuje lepší soukromí metadat než alternativy.", "simplex-unique-4-overlay-1-title": "Plně decentralizované — uživatelé vlastní síť SimpleX", - "hero-overlay-card-1-p-6": "Více v SimpleX whitepaper.", + "hero-overlay-card-1-p-6": "Více v SimpleX whitepaper.", "hero-overlay-card-1-p-2": "K doručování zpráv používá SimpleX namísto ID uživatelů používaných všemi ostatními sítěmi, dočasné anonymní párové identifikátory front zpráv, oddělené pro každé z vašich připojení — neexistují žádné dlouhodobé identifikátory.", "hero-overlay-card-1-p-3": "Definujete, které servery se mají používat k přijímání zpráv, vašich kontaktů — servery, které používáte k odesílání zpráv. Každá konverzace bude pravděpodobně používat dva různé servery.", "hero-overlay-card-2-p-3": "I v těch nejsoukromějších aplikacích, které používají služby Tor v3, pokud mluvíte se dvěma různými kontakty prostřednictvím stejného profilu, může být prokázáno, že jsou spojeni se stejnou osobou.", @@ -89,11 +89,11 @@ "simplex-network-overlay-card-1-li-3": "P2P neřeší problém MITM útoku a většina existujících implementací nepoužívá out-of-band zprávy pro počáteční výměnu klíčů. SimpleX používá out-of-band zprávy nebo v některých případech již existující zabezpečená a důvěryhodná připojení pro počáteční výměnu klíčů.", "simplex-network-overlay-card-1-li-4": "Implementace P2P mohou být některými poskytovateli internetu blokovány (například BitTorrent). SimpleX je transportně nezávislý — může pracovat přes standardní webové protokoly, např. WebSockety.", "privacy-matters-overlay-card-1-p-1": "Mnoho velkých společností používá informace o tom, s kým jste ve spojení, k odhadu vašich příjmů, prodeji produktů, které ve skutečnosti nepotřebujete, a ke stanovení cen.", - "privacy-matters-overlay-card-1-p-3": "Některé finanční a pojišťovací společnosti používají sociální grafy k určení úrokových sazeb a pojistného. Často přiměje lidi s nižšími příjmy platit více —, je to známé jako \"prémie za chudobu\".", - "privacy-matters-overlay-card-2-p-1": "Není to tak dávno, co jsme pozorovali, jak velké volby zmanipulovala renomovaná poradenská společnost, která používala naše sociální grafy ke zkreslení našeho pohledu na skutečný svět a manipulovala s našimi hlasy.", + "privacy-matters-overlay-card-1-p-3": "Některé finanční a pojišťovací společnosti používají sociální grafy k určení úrokových sazeb a pojistného. Často přiměje lidi s nižšími příjmy platit více —, je to známé jako \"prémie za chudobu\".", + "privacy-matters-overlay-card-2-p-1": "Není to tak dávno, co jsme pozorovali, jak velké volby zmanipulovala renomovaná poradenská společnost, která používala naše sociální grafy ke zkreslení našeho pohledu na skutečný svět a manipulovala s našimi hlasy.", "privacy-matters-overlay-card-2-p-3": "SimpleX je první síť, která nemá žádné uživatelské identifikátory záměrně, a tímto způsobem chrání váš graf připojení lépe než jakákoli známá alternativa.", "privacy-matters-overlay-card-3-p-1": "Každý by se měl starat o soukromí a bezpečnost své komunikace — neškodné rozhovory vás mohou vystavit nebezpečí, i když nemáte co skrývat.", - "privacy-matters-overlay-card-3-p-3": "Obyčejní lidé jsou zatčeni za to, co sdílejí online, dokonce i prostřednictvím svých \"anonymních\" účtů, i v demokratických zemích.", + "privacy-matters-overlay-card-3-p-3": "Obyčejní lidé jsou zatčeni za to, co sdílejí online, dokonce i prostřednictvím svých \"anonymních\" účtů, i v demokratických zemích.", "privacy-matters-overlay-card-3-p-4": "Nestačí používat end-to-end šifrovaný messenger, všichni bychom měli používat messengery, které chrání soukromí našich osobních sítí — s kým jsme spojeni.", "simplex-unique-overlay-card-1-p-3": "Tento design chrání soukromí těch, s kým komunikujete, skrývá ho před servery sítě SimpleX a před jakýmikoli pozorovateli. Pro skrytí své IP adresy před servery, můžete se připojit k serverům SimpleX přes Tor.", "simplex-unique-overlay-card-2-p-1": "Protože na síti SimpleX nemáte žádný identifikátor, nikdo vás nemůže kontaktovat pokud nenasdílíte jednorázovou nebo dočasnou uživatelskou adresu, jako QR kód nebo odkaz.", @@ -101,7 +101,7 @@ "simplex-unique-overlay-card-3-p-2": "End-to-end šifrované zprávy jsou dočasně uchovávány na přenosových serverech SimpleX, dokud nejsou přijaty, poté jsou trvale odstraněny.", "simplex-unique-overlay-card-3-p-3": "Na rozdíl od serverů federovaných sítí (e-mail, XMPP nebo Matrix) servery SimpleX neukládají uživatelské účty, pouze předávají zprávy, čímž chrání soukromí obou stran.", "simplex-unique-overlay-card-4-p-1": "Můžete použít SimpleX se svými vlastními servery a přesto komunikovat s lidmi, kteří používají přednastavené servery v aplikacích.", - "simplex-unique-overlay-card-4-p-3": "Pokud uvažujete o vývoji pro SimpleX síť, například chat bot pro uživatele aplikace SimpleX nebo integraci knihovny SimpleX chat do Vasí mobilní aplikace, prosím buďte ve spojení pro jakoukoli radu a podporu.", + "simplex-unique-overlay-card-4-p-3": "Pokud uvažujete o vývoji pro SimpleX síť, například chat bot pro uživatele aplikace SimpleX nebo integraci knihovny SimpleX chat do Vasí mobilní aplikace, prosím buďte ve spojení pro jakoukoli radu a podporu.", "simplex-unique-card-1-p-1": "SimpleX chrání soukromí vašeho profilu, kontaktů a metadat a skrývá je před servery SimpleX sítě a jakýmikoli pozorovateli.", "simplex-unique-card-1-p-2": "Na rozdíl od jakékoli jiné existující síti pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům — ani náhodná čísla.", "simplex-unique-card-3-p-1": "SimpleX Chat ukládá všechna uživatelská data pouze na klientských zařízeních pomocí přenosného šifrovaného databázového formátu, který lze exportovat a přenést na jakékoli podporované zařízení.", @@ -181,14 +181,14 @@ "privacy-matters-overlay-card-1-p-4": "SimpleX síť chrání soukromí vašich připojení lépe než jakákoli jiná alternativa a plně zabraňuje tomu, aby byl váš sociální graf dostupný všem společnostem nebo organizacím. I když lidé používají servery přednastavené v aplikaci SimpleX Chat, operátoři serverů neznají počet uživatelů ani jejich připojení.", "privacy-matters-overlay-card-2-p-2": "Chcete-li být objektivní a činit nezávislá rozhodnutí, musíte mít svůj informační prostor pod kontrolou. Je to možné pouze v případě, že používáte soukromou komunikační síť, která nemá přístup k vašemu sociálnímu grafu.", "simplex-unique-overlay-card-1-p-2": "K doručování zpráv SimpleX používá párové anonymní adresy jednosměrných front zpráv, oddělených pro přijaté a odeslané zprávy, obvykle přes různé servery.", - "privacy-matters-overlay-card-3-p-2": "Jedním z nejvíce šokujících příběhů je zkušenost Mohamedoua Oulda Salahiho popsaná v jeho pamětech a zobrazená v Mauritánském filmu. Byl umístěn do tábora na Guantánamu bez soudu a byl tam 15 let mučen po telefonátu svému příbuznému v Afghánistánu pro podezření z účasti na útocích z 11. září, i když předchozích 10 let žil v Německu.", + "privacy-matters-overlay-card-3-p-2": "Jedním z nejvíce šokujících příběhů je zkušenost Mohamedoua Oulda Salahiho popsaná v jeho pamětech a zobrazená v Mauritánském filmu. Byl umístěn do tábora na Guantánamu bez soudu a byl tam 15 let mučen po telefonátu svému příbuznému v Afghánistánu pro podezření z účasti na útocích z 11. září, i když předchozích 10 let žil v Německu.", "simplex-unique-overlay-card-1-p-1": "Na rozdíl od jiných sítí pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům. Nespoléhá na telefonní čísla, adresy založené na doméně (jako e-mail nebo XMPP), uživatelská jména, veřejné klíče nebo dokonce náhodná čísla k identifikaci svých uživatelů — Operátoři SimpleX serverů neví, kolik lidí používá jejich servery.", "invitation-hero-header": "Byl vám zaslán odkaz pro připojení na SimpleX Chat", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat ukládá všechna uživatelská data pouze na klientských zařízeních pomocí přenosného šifrovaného databázového formátu, který lze exportovat a přenést na jakékoli podporované zařízení.", "contact-hero-p-1": "Veřejné klíče a adresa fronty zpráv v tomto odkazu NEJSOU při zobrazení této stránky odesílány přes síť — jsou obsaženy ve fragmentu kontrolního součtu adresy URL odkazu.", "simplex-unique-overlay-card-3-p-4": "Mezi odeslaným a přijatým provozem serveru nejsou žádné společné identifikátory ani šifrovaný text — pokud to někdo pozoruje, nemůže snadno určit, kdo s kým komunikuje, i když je TLS kompromitován.", "simplex-unique-card-2-p-1": "Protože na SimpleX síti nemáte žádný identifikátor nebo pevnou adresu, nikdo vás nemůže kontaktovat pokud nenasdílíte jednorázovou nebo dočasnou uživatelskou adresu, jako QR kód nebo odkaz.", - "simplex-unique-overlay-card-4-p-2": "SimpleX síť používá otevřený protokol a poskytuje SDK k vytváření chat botů, což umožní implementaci služeb, díky nimž boudou moci uživatelé komunikovat prostřednictvím aplikací SimpleX Chat — Opravdu se těšíme, jaké služby SimpleX vytvoříte.", + "simplex-unique-overlay-card-4-p-2": "SimpleX síť používá otevřený protokol a poskytuje SDK k vytváření chat botů, což umožní implementaci služeb, díky nimž boudou moci uživatelé komunikovat prostřednictvím aplikací SimpleX Chat — Opravdu se těšíme, jaké služby SimpleX vytvoříte.", "simplex-network": "SimpleX síť", "simplex-explained-tab-2-p-1": "Pro každé připojení používáte dvě samostatné fronty zasílání zpráv k odesílání a přijímání zpráv prostřednictvím různých serverů.", "simplex-explained-tab-1-p-1": "Můžete vytvářet kontakty a skupiny a vést obousměrné konverzace, stejně jako v jakémkoli jiném messengeru.", @@ -201,7 +201,7 @@ "simplex-private-8-title": "Smíchání zpráv
ke snížení korelace", "simplex-unique-1-overlay-1-title": "Úplné soukromí vaší identity, profilu, kontaktů a metadat", "simplex-private-card-2-point-1": "Další vrstva šifrování serveru pro doručení příjemci, aby se zabránilo korelaci mezi přijatým a odeslaným serverovým provozem, pokud je protokol TLS ohrožen.", - "simplex-private-card-4-point-2": "Pro použití SimpleX přes Tor prosím nainstalujte Orbot aplikaci a povolte SOCKS5 proxy (nebo VPN na iOS).", + "simplex-private-card-4-point-2": "Pro použití SimpleX přes Tor prosím nainstalujte Orbot aplikaci a povolte SOCKS5 proxy (nebo VPN na iOS).", "simplex-private-card-5-point-1": "SimpleX používá odsazení obsahu pro každou šifrovací vrstvu, aby zmařil útoky na velikost zprávy.", "simplex-private-card-6-point-2": "Aby se tomu zabránilo, aplikace SimpleX předávají jednorázové klíče mimo pásmo, když sdílíte adresu jako odkaz nebo QR kód.", "simplex-private-card-8-point-1": "Servery SimpleX fungují jako smíšené uzly s nízkou latencí — příchozí a odchozí zprávy mají různá pořadí.", diff --git a/website/langs/de.json b/website/langs/de.json index ac35879b3a..34cecb8e4d 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -62,7 +62,7 @@ "simplex-private-card-3-point-2": "Server-Fingerabdrücke und Kanalbindung verhindern MITM- und Replay-Angriffe.", "simplex-private-card-3-point-3": "Die Wiederaufnahme von Verbindungen ist deaktiviert, um Sitzungs-Angriffe zu verhindern.", "simplex-private-card-4-point-1": "Um Ihre IP-Adresse zu schützen, können Sie per Tor oder irgendeinem anderen Transportschichten-Netzwerk auf Server zugreifen.", - "simplex-private-card-4-point-2": "Um SimpleX per Tor zu nutzen, installieren Sie unter Android bitte die Orbot-App und aktivieren Sie den SOCKS5-Proxy oder unter iOS per VPN.", + "simplex-private-card-4-point-2": "Um SimpleX per Tor zu nutzen, installieren Sie unter Android bitte die Orbot-App und aktivieren Sie den SOCKS5-Proxy oder unter iOS per VPN.", "simplex-private-card-5-point-1": "SimpleX nutzt Inhalteauffüllung für jede Verschlüsselungs-Schicht, um Angriffe auf die Nachrichtengröße zu vereiteln.", "simplex-private-card-5-point-2": "Erzeugt Nachrichten mit unterschiedlichen Größen, die für Server und Netzwerk-Beobachter identisch aussehen.", "simplex-private-card-6-point-1": "Viele Kommunikations-Netzwerke sind für MITM-Angriffe durch Server oder Netzwerk-Anbieter anfällig.", @@ -93,7 +93,7 @@ "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.", @@ -106,7 +106,7 @@ "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, das konzeptionell ohne Benutzerkennungen auskommt und dadurch Ihren Verbindungsgraphen besser schützt als jede 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.", @@ -176,8 +176,8 @@ "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.", @@ -197,14 +197,14 @@ "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-unique-overlay-card-3-p-4": "Zwischen dem gesendeten und dem 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 einsehbar 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": "Selbst in demokratischen Ländern werden normale Menschen, auch unter Nutzung ihrer „anonymen“ Benutzerkonten, für das, was sie online teilen, verhaftet.", + "privacy-matters-overlay-card-3-p-3": "Selbst in demokratischen Ländern werden normale Menschen, auch unter Nutzung ihrer „anonymen“ Benutzerkonten, für das, was sie online teilen, 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 freuen uns sehr darauf zu sehen, 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 freuen uns sehr darauf zu sehen, welche SimpleX-Dienste Sie entwickeln werden.", "simplex-unique-card-4-p-2": "Sie können SimpleX mit 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", "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.", diff --git a/website/langs/en.json b/website/langs/en.json index 24dde29b39..6138b34c8c 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -66,7 +66,7 @@ "simplex-private-card-3-point-2": "Server fingerprint and channel binding prevent MITM and replay attacks.", "simplex-private-card-3-point-3": "Connection resumption is disabled to prevent session attacks.", "simplex-private-card-4-point-1": "To protect your IP address you can access the servers via Tor or some other transport overlay network.", - "simplex-private-card-4-point-2": "To use SimpleX via Tor please install Orbot app and enable SOCKS5 proxy (or VPN on iOS).", + "simplex-private-card-4-point-2": "To use SimpleX via Tor please install Orbot app and enable SOCKS5 proxy (or VPN on iOS).", "simplex-private-card-5-point-1": "SimpleX uses content padding for each encryption layer to frustrate message size attacks.", "simplex-private-card-5-point-2": "It makes messages of different sizes look the same to the servers and network observers.", "simplex-private-card-6-point-1": "Many communication networks are vulnerable to MITM attacks by servers or network providers.", @@ -100,7 +100,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.", @@ -117,14 +117,14 @@ "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 connections 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 from 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 a 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-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.", @@ -136,8 +136,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 seeing 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 seeing 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.", @@ -291,7 +291,7 @@ "index-token-p2": "This will cover infrastructure, software development and network governance.", "index-token-cta": "Learn more about Community Credits", "index-token-p2-cf": "And the users can now invest in SimpleX Chat — the equity crowdfunding is live!", - "index-token-cta-cf": "Get a stake in SimpleX Chat", + "index-token-cta-cf": "Invest in SimpleX Chat", "index-roadmap-h2": "SimpleX Roadmap to Free Internet", "index-roadmap-now": "Now", "index-roadmap-1": "2026", diff --git a/website/langs/es.json b/website/langs/es.json index c5d4c5291e..22b69cfbeb 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -32,7 +32,7 @@ "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-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 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.", @@ -84,7 +84,7 @@ "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 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 el SimpleX whitepaper.", + "hero-overlay-card-1-p-6": "Encontrarás más información en el SimpleX 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 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.", @@ -105,7 +105,7 @@ "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-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.", @@ -119,10 +119,10 @@ "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 conexiones conocidas 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 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-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.", + "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.", @@ -185,8 +185,8 @@ "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 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 ver con interés 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-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 ver con interés 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.", diff --git a/website/langs/fa.json b/website/langs/fa.json index 0afd6a8f83..5a369c86e2 100644 --- a/website/langs/fa.json +++ b/website/langs/fa.json @@ -63,14 +63,14 @@ "simplex-network-overlay-card-1-li-6": "شبکه‌های P2P ممکن است در معرض حمله DRDoS قرار بگیرند، زمانی که کلاینت‌ها می‌توانند ترافیک را دوباره پخش و تقویت کنند که منجر به عدم دسترسی به خدمات در سطح شبکه می‌شود. کلاینت‌های SimpleX تنها ترافیک را از اتصالات شناخته‌شده منتقل می‌کنند و نمی‌توانند توسط یک مهاجم برای تقویت ترافیک در کل شبکه استفاده شوند.", "privacy-matters-overlay-card-1-p-1": "بسیاری از شرکت‌های بزرگ از اطلاعات مربوط به اینکه با چه کسانی ارتباط دارید، برای تخمین درآمد شما، فروش محصولات غیرضروری به شما و تعیین قیمت‌ها استفاده می‌کنند.", "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 استفاده می‌کنند، اپراتورهای سرور از تعداد کاربران یا ارتباطات آن‌ها اطلاعی ندارند.", - "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-1": "همه باید به حریم خصوصی و امنیت ارتباطات خود اهمیت دهند — گفتگوهای بی‌ضرر می‌توانند شما را در خطر قرار دهند، حتی اگر چیزی برای پنهان کردن نداشته باشید.", - "privacy-matters-overlay-card-3-p-2": "یکی از شگفت‌انگیزترین داستان‌ها، تجربه محمدو ولد صلاحی است که در یادداشت‌هایش توصیف شده و در فیلم \"موریتانی\" به نمایش درآمده است. او بدون محاکمه به اردوگاه گوانتانامو منتقل شد و به مدت ۱۵ سال تحت شکنجه قرار گرفت. این اتفاق پس از یک تماس تلفنی با یکی از بستگانش در افغانستان رخ داد. او به ظن ارتباط با حملات ۱۱ سپتامبر، بدون محاکمه به اردوگاه گوانتانامو منتقل شد.", - "privacy-matters-overlay-card-3-p-3": "افراد عادی به خاطر آنچه که به‌صورت آنلاین به اشتراک می‌گذارند، حتی از طریق حساب‌های 'ناشناس' خود، حتی در کشورهای دموکراتیک دستگیر می‌شوند.", + "privacy-matters-overlay-card-3-p-2": "یکی از شگفت‌انگیزترین داستان‌ها، تجربه محمدو ولد صلاحی است که در یادداشت‌هایش توصیف شده و در فیلم \"موریتانی\" به نمایش درآمده است. او بدون محاکمه به اردوگاه گوانتانامو منتقل شد و به مدت ۱۵ سال تحت شکنجه قرار گرفت. این اتفاق پس از یک تماس تلفنی با یکی از بستگانش در افغانستان رخ داد. او به ظن ارتباط با حملات ۱۱ سپتامبر، بدون محاکمه به اردوگاه گوانتانامو منتقل شد.", + "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 از آدرس‌های ناشناس جفتی در صف‌های پیام یک‌طرفه استفاده می‌کند که برای پیام‌های دریافتی و ارسال‌شده جداگانه هستند و معمولاً از طریق سرورهای مختلف انجام می‌شود.", @@ -82,8 +82,8 @@ "simplex-unique-overlay-card-3-p-3": "برخلاف سرورهای شبکه‌های فدرال (ایمیل، XMPP یا Matrix)، سرورهای SimpleX حساب‌های کاربری را ذخیره نمی‌کنند و تنها پیام‌ها را منتقل می‌کنند، که حریم خصوصی هر دو طرف را محافظت می‌کند.", "simplex-unique-overlay-card-3-p-4": "هیچ شناسه یا متن رمز مشترکی بین ترافیک سرور ارسال‌شده و دریافتی وجود ندارد — اگر کسی در حال نظارت باشد، نمی‌تواند به‌راحتی تشخیص دهد که چه کسی با چه کسی ارتباط برقرار می‌کند، حتی اگر TLS به خطر بیفتد.", "simplex-unique-overlay-card-4-p-1": "شما می‌توانید از SimpleX با سرورهای خود استفاده کنید و همچنان با افرادی که از سرورهای پیش‌پیکربندی‌شده در برنامه‌ها استفاده می‌کنند، ارتباط برقرار کنید.", - "simplex-unique-overlay-card-4-p-2": "شبکه SimpleX از یک پروتکل باز استفاده می‌کند و SDK برای ایجاد ربات‌های چت ارائه می‌دهد، که امکان پیاده‌سازی خدماتی را فراهم می‌کند که کاربران می‌توانند از طریق برنامه‌های چت SimpleX با آن‌ها تعامل داشته باشند — ما واقعاً منتظر هستیم ببینیم چه خدماتی را با SimpleX ایجاد خواهید کرد.", - "simplex-unique-overlay-card-4-p-3": "اگر به توسعه برای شبکه SimpleX فکر می‌کنید، مثلاً ربات چت برای کاربران برنامه SimpleX یا ادغام کتابخانه چت SimpleX در برنامه‌های موبایل خود، لطفاً برای هرگونه مشاوره و پشتیبانی با ما تماس بگیرید.", + "simplex-unique-overlay-card-4-p-2": "شبکه SimpleX از یک پروتکل باز استفاده می‌کند و SDK برای ایجاد ربات‌های چت ارائه می‌دهد، که امکان پیاده‌سازی خدماتی را فراهم می‌کند که کاربران می‌توانند از طریق برنامه‌های چت SimpleX با آن‌ها تعامل داشته باشند — ما واقعاً منتظر هستیم ببینیم چه خدماتی را با SimpleX ایجاد خواهید کرد.", + "simplex-unique-overlay-card-4-p-3": "اگر به توسعه برای شبکه SimpleX فکر می‌کنید، مثلاً ربات چت برای کاربران برنامه SimpleX یا ادغام کتابخانه چت SimpleX در برنامه‌های موبایل خود، لطفاً برای هرگونه مشاوره و پشتیبانی با ما تماس بگیرید.", "simplex-unique-card-1-p-1": "SimpleX حریم خصوصی پروفایل، مخاطبین و فراداده‌های شما را محافظت می‌کند و آن‌ها را از سرورهای شبکه SimpleX و هر ناظر دیگری پنهان می‌سازد.", "simplex-unique-card-1-p-2": "برخلاف هر شبکه پیام‌رسان دیگری، SimpleX هیچ شناسه‌ای به کاربران اختصاص نمی‌دهد — حتی شماره‌های تصادفی نیز وجود ندارد.", "simplex-unique-card-2-p-1": "از آنجا که شما هیچ شناسه یا آدرس ثابتی در شبکه SimpleX ندارید، هیچ‌کس نمی‌تواند با شما تماس بگیرد مگر اینکه یک آدرس کاربری یک‌بار مصرف یا موقتی را به‌عنوان کد QR یا لینک به اشتراک بگذارید.", @@ -214,7 +214,7 @@ "simplex-private-card-3-point-2": "اثر انگشت سرور و اتصال کانال از حملات MITM و حملات replay جلوگیری می‌کنند.", "simplex-private-card-3-point-3": "برای جلوگیری از حملات نشست، از سرگیری اتصال غیرفعال شده است.", "simplex-private-card-4-point-1": "برای محافظت از آدرس IP خود، می‌توانید از طریق Tor یا برخی دیگر از شبکه‌های حمل و نقل پوششی به سرورها دسترسی پیدا کنید.", - "simplex-private-card-4-point-2": "برای استفاده از SimpleX از طریق Tor، لطفاً برنامه Orbot را نصب کرده و پروکسی SOCKS5 (یا VPN در iOS) را فعال کنید.", + "simplex-private-card-4-point-2": "برای استفاده از SimpleX از طریق Tor، لطفاً برنامه Orbot را نصب کرده و پروکسی SOCKS5 (یا VPN در iOS) را فعال کنید.", "simplex-private-card-5-point-1": "SimpleX از padding محتوا برای هر لایه رمزنگاری استفاده می‌کند تا از حملات اندازه پیام جلوگیری کند.", "simplex-private-card-5-point-2": "این کار باعث می‌شود که پیام‌های با اندازه‌های مختلف برای سرورها و ناظران شبکه یکسان به نظر برسند.", "simplex-private-card-6-point-1": "بسیاری از شبکه‌های ارتباطی در برابر حملات MITM توسط سرورها یا ارائه‌دهندگان شبکه آسیب‌پذیر هستند.", @@ -248,7 +248,7 @@ "hero-overlay-card-1-p-3": "شما سرورهایی را که برای ارسال پیام‌ها به مخاطبین خود استفاده می‌کنید مشخص می‌کنید و همچنین از کدام سرور(ها) برای دریافت پیام‌ها بهره می‌برید. هر مکالمه احتمالاً از دو سرور مختلف استفاده می‌کند.", "hero-overlay-card-1-p-4": "این طراحی از نشت هرگونه فراداده کاربران در سطح برنامه جلوگیری می‌کند. برای بهبود بیشتر حریم خصوصی و محافظت از آدرس IP خود، می‌توانید از طریق Tor به سرورهای پیام‌رسانی متصل شوید.", "hero-overlay-card-1-p-5": "فقط دستگاه‌های کلاینت پروفایل‌های کاربری، مخاطبین و گروه‌ها را ذخیره می‌کنند؛ پیام‌ها با رمزنگاری انتها به انتهای دو لایه ارسال می‌شوند.", - "hero-overlay-card-1-p-6": "برای اطلاعات بیشتر به وایت پیپر SimpleX مراجعه کنید.", + "hero-overlay-card-1-p-6": "برای اطلاعات بیشتر به وایت پیپر SimpleX مراجعه کنید.", "hero-overlay-card-2-p-1": "زمانی که کاربران هویت‌های پایدار دارند، حتی اگر این فقط یک شماره تصادفی مانند شناسه جلسه باشد، خطر این وجود دارد که ارائه‌دهنده یا یک مهاجم بتوانند مشاهده کنند که کاربران چگونه متصل شده‌اند و چند پیام ارسال می‌کنند.", "hero-overlay-card-2-p-2": "سپس آن‌ها می‌توانند این اطلاعات را با شبکه‌های اجتماعی عمومی موجود همبسته کرده و برخی هویت‌های واقعی را شناسایی کنند.", "hero-overlay-card-2-p-3": "حتی با خصوصی‌ترین برنامه‌ها که از خدمات Tor v3 استفاده می‌کنند، اگر شما با دو مخاطب مختلف از طریق یک پروفایل یکسان صحبت کنید، آن‌ها می‌توانند ثابت کنند که به یک شخص واحد متصل هستند.", diff --git a/website/langs/fi.json b/website/langs/fi.json index 3a138aa7c4..363697b8fb 100644 --- a/website/langs/fi.json +++ b/website/langs/fi.json @@ -37,7 +37,7 @@ "simplex-private-9-title": "Yksisuuntaisia
viestijonoja", "simplex-private-10-title": "Väliaikaiset nimettömät parittaiset tunnisteet", "simplex-private-card-3-point-1": "Vain TLS 1.2/1.3 vahvoilla algoritmeilla käytetään asiakas-palvelin-yhteyksiin.", - "simplex-private-card-4-point-2": "Käyttääksesi SimpleX:ää Torin kautta, asenna Orbot-sovellus ja ota käyttöön SOCKS5-välityspalvelin (tai VPN iOS:lla).", + "simplex-private-card-4-point-2": "Käyttääksesi SimpleX:ää Torin kautta, asenna Orbot-sovellus ja ota käyttöön SOCKS5-välityspalvelin (tai VPN iOS:lla).", "simplex-private-card-7-point-1": "Viestien eheyden varmistamiseksi ne numeroituvat peräkkäin ja sisältävät edellisen viestin tiivisteen.", "simplex-private-card-7-point-2": "Jos viestiä lisätään, poistetaan tai muutetaan, vastaanottaja saa ilmoituksen.", "simplex-private-card-8-point-1": "SimpleX-palvelimet toimivat matalan viiveen sekoitussolmuina — saapuvilla ja lähtevillä viesteillä on erilainen järjestys.", @@ -152,7 +152,7 @@ "simplex-unique-4-overlay-1-title": "Täysin hajautettu — käyttäjät omistavat SimpleX-verkon", "hero-overlay-card-1-p-1": "Monet käyttäjät ovat kysyneet: jos SimpleX ei käytä käyttäjätunnisteita, miten se tietää minne viestit toimitetaan?", "hero-overlay-card-1-p-3": "Määrität, minkä palvelimen(t) valitset viestien vastaanottamiseen, sekä kontaktisi — palvelimet, joita käytät viestien lähettämiseen heille. Jokainen keskustelu käyttää todennäköisesti kahta eri palvelinta.", - "hero-overlay-card-1-p-6": "Lue lisää SimpleX whitepaperista.", + "hero-overlay-card-1-p-6": "Lue lisää SimpleX whitepaperista.", "hero-overlay-card-1-p-2": "Viestien toimittamiseen SimpleX ei käytä muiden alustojen käyttäjätunnuksia, vaan sen sijaan se käyttää väliaikaisia nimettömiä parittaisia tunnisteita viestijonoille, jotka ovat erillisiä jokaiselle yhteydelle — pitkäaikaisia tunnisteita ei ole.", "hero-overlay-card-2-p-3": "Jopa yksityisimmillä sovelluksilla, jotka käyttävät Tor v3 -palveluja, jos puhut kahdelle eri yhteyshenkilölle saman profiilin kautta, he voivat todistaa olevansa yhteydessä samaan henkilöön.", "hero-overlay-card-2-p-4": "SimpleX suojaa näitä hyökkäyksiä vastaan, sillä siinä ei ole käyttäjätunnuksia toteutuksessaan. Ja jos käytät Incognito-tilaa, sinulla on eri näyttönimi jokaiselle yhteyshenkilölle, mikä estää yhteisten tietojen jakamisen heidän välillään.", @@ -163,14 +163,14 @@ "simplex-network-overlay-card-1-li-3": "P2P ei ratkaise välikäden hyökkäys -ongelmaa, ja useimmat olemassa olevat toteutukset eivät käytä kanavan ulkopuolisia viestejä alkuperäiseen avaimenvaihtoon. SimpleX käyttää kanavan ulkopuolisia viestejä tai, joissakin tapauksissa, jo valmiiksi turvallisia ja luotettuja yhteyksiä alkuperäiseen avaimenvaihtoon.", "simplex-network-overlay-card-1-li-4": "P2P-toteutukset voivat estyä joidenkin Internet-palveluntarjoajien (kuten BitTorrent) toimesta. SimpleX on kuljetusprotokollasta riippumaton - se voi toimia yleisten verkkoprotokollien kautta, kuten esimerkiksi WebSockets.", "simplex-network-overlay-card-1-li-5": "Kaikki tunnetut P2P-verkot saattavat olla alttiita Sybil-hyökkäykselle, koska jokainen solmu on löydettävissä, ja verkko toimii kokonaisuutena. Tunnetut keinot sen lieventämiseksi vaativat joko keskitetyn komponentin tai kalliin työn todistuksen. SimpleX-verkolla ei ole palvelimen löydettävyyttä, se on fragmentoitunut ja toimii useina eristettyinä aliverkkoina, mikä tekee verkko-laajuisista hyökkäyksistä mahdottomia.", - "privacy-matters-overlay-card-1-p-3": "Jotkut rahoitus- ja vakuutusyhtiöt käyttävät sosiaalisia verkostoja määrittääkseen korkoja ja vakuutusmaksuja. Se tekee usein alhaisempiin tuloihin kuuluvien ihmisten maksavan enemmän — sitä kutsutaan 'köyhyyslisäksi'.", + "privacy-matters-overlay-card-1-p-3": "Jotkut rahoitus- ja vakuutusyhtiöt käyttävät sosiaalisia verkostoja määrittääkseen korkoja ja vakuutusmaksuja. Se tekee usein alhaisempiin tuloihin kuuluvien ihmisten maksavan enemmän — sitä kutsutaan 'köyhyyslisäksi'.", "privacy-matters-overlay-card-1-p-4": "SimpleX-alusta suojaa yhteyksiesi yksityisyyttä paremmin kuin mikään vaihtoehto, estäen täysin yhteysverkkosi tulemisen saataville mille tahansa yrityksille tai organisaatioille. Vaikka ihmiset käyttävät SimpleX Chatin tarjoamia palvelimia, emme tiedä käyttäjien määrää tai heidän yhteyksiään.", "privacy-matters-overlay-card-2-p-3": "SimpleX on ensimmäinen alusta, jolla ei ole mitään käyttäjätunnisteita suunnittelussaan, suojaten siten yhteyksesi verkkoa paremmin kuin mikään tunnettu vaihtoehto.", - "privacy-matters-overlay-card-2-p-1": "Ei niin kauan sitten huomasimme merkittävien vaalien olevan manipuloitavissa kunnioitetun konsulttiyrityksen toimesta, joka käytti sosiaalisia verkostoja vääristämään käsitystämme todellisesta maailmasta ja manipuloimaan ääniämme.", + "privacy-matters-overlay-card-2-p-1": "Ei niin kauan sitten huomasimme merkittävien vaalien olevan manipuloitavissa kunnioitetun konsulttiyrityksen toimesta, joka käytti sosiaalisia verkostoja vääristämään käsitystämme todellisesta maailmasta ja manipuloimaan ääniämme.", "privacy-matters-overlay-card-3-p-1": "Kaikkien pitäisi välittää viestinnän yksityisyydestä ja turvallisuudesta — harmittomat keskustelut voivat asettaa sinut vaaraan, vaikka sinulla ei olisi mitään piilotettavaa.", "privacy-matters-overlay-card-2-p-2": "Ollaksesi objektiivinen ja tehdäksesi itsenäisiä päätöksiä, sinun on hallittava tietotilaasi. Se on mahdollista vain, jos käytät yksityistä viestintäalustaa, jolla ei ole pääsyä sosiaaliseen verkostoosi.", - "privacy-matters-overlay-card-3-p-2": "Yksi järkyttävimmistä tarinoista on Mohamedou Ould Salahi'n kokemus, joka on kuvattu hänen muistelmissaan ja esitetty The Mauritanian -elokuvassa. Hänet laitettiin Guantanamo-leirille ilman oikeudenkäyntiä ja häntä kidutettiin siellä 15 vuotta puhelun jälkeen sukulaiselleen Afganistanissa, epäiltynä osallisuudesta 9/11-iskuihin, vaikka hän oli asunut Saksassa edelliset 10 vuotta.", - "privacy-matters-overlay-card-3-p-3": "Tavalliset ihmiset pidätetään siitä, mitä he jakavat verkossa, jopa 'anonyymien' tiliensä kautta, jopa demokraattisissa maissa.", + "privacy-matters-overlay-card-3-p-2": "Yksi järkyttävimmistä tarinoista on Mohamedou Ould Salahi'n kokemus, joka on kuvattu hänen muistelmissaan ja esitetty The Mauritanian -elokuvassa. Hänet laitettiin Guantanamo-leirille ilman oikeudenkäyntiä ja häntä kidutettiin siellä 15 vuotta puhelun jälkeen sukulaiselleen Afganistanissa, epäiltynä osallisuudesta 9/11-iskuihin, vaikka hän oli asunut Saksassa edelliset 10 vuotta.", + "privacy-matters-overlay-card-3-p-3": "Tavalliset ihmiset pidätetään siitä, mitä he jakavat verkossa, jopa 'anonyymien' tiliensä kautta, jopa demokraattisissa maissa.", "privacy-matters-overlay-card-3-p-4": "Ei riitä, että käytät päästä päähän salattua viestintäsovellusta, meidän kaikkien pitäisi käyttää viestintäsovelluksia, jotka suojelevat henkilökohtaisten verkostojemme yksityisyyttä — keiden kanssa olemme yhteydessä.", "simplex-unique-overlay-card-1-p-1": "Toisin kuin muut viestintäalustat, SimpleX:llä ei ole mitään tunnisteita käyttäjille. Se ei luota puhelinnumeroihin, verkkotunnuksiin perustuviin osoitteisiin (kuten sähköposti tai XMPP), käyttäjänimiin, julkisiin avaimiin tai edes satunnaisiin numeroihin tunnistaakseen käyttäjänsä — emme tiedä kuinka monta ihmistä käyttää SimpleX-palvelimiamme.", "simplex-unique-overlay-card-1-p-2": "Viestien toimittamiseksi SimpleX käyttää parittaisia nimettömiä osoitteita kaksisuuntaisille viestijonoille, jotka ovat erilliset vastaanotetuille ja lähetetyille viesteille, yleensä eri palvelimien kautta.", @@ -179,9 +179,9 @@ "simplex-unique-overlay-card-2-p-2": "Jopa valinnaisen käyttäjäosoitteen kanssa, vaikka sitä voitaisiin käyttää roskapostiyhteyspyyntöjen lähettämiseen, voit vaihtaa sen tai poistaa sen kokonaan menettämättä mitään yhteyksiäsi.", "simplex-unique-overlay-card-3-p-3": "Toisin kuin liitettyjen verkkojen palvelimet (sähköposti, XMPP tai Matrix), SimpleX-palvelimet eivät tallenna käyttäjätilejä, ne vain välittävät viestejä, suojaten molempien osapuolien yksityisyyttä.", "simplex-unique-overlay-card-3-p-4": "Lähetetyssä ja vastaanotetussa palvelimen liikenteessä ei ole yhteisiä tunnisteita tai salaustekstiä — jos joku havaitsee sen, he eivät voi helposti selvittää kuka kommunikoi kenen kanssa, vaikka TLS olisi vaarantunut.", - "simplex-unique-overlay-card-4-p-3": "Jos harkitset kehittämistä SimpleX-alustalle, esimerkiksi chat-botin luomista SimpleX-sovelluksen käyttäjille tai SimpleX Chat -kirjaston integrointia mobiilisovelluksiisi, ole hyvä ja ota yhteyttä saadaksesi neuvoja ja tukea.", + "simplex-unique-overlay-card-4-p-3": "Jos harkitset kehittämistä SimpleX-alustalle, esimerkiksi chat-botin luomista SimpleX-sovelluksen käyttäjille tai SimpleX Chat -kirjaston integrointia mobiilisovelluksiisi, ole hyvä ja ota yhteyttä saadaksesi neuvoja ja tukea.", "simplex-unique-overlay-card-4-p-1": "Voit käyttää SimpleX:ää omien palvelimiesi kanssa ja silti kommunikoida ihmisten kanssa, jotka käyttävät meille tarjottuja valmiiksi määritettyjä palvelimia.", - "simplex-unique-overlay-card-4-p-2": "SimpleX-alusta käyttää avoimeen protokollaan ja tarjoaa SDK:n chatbotien luomiseen, mahdollistaen palvelujen toteuttamisen, joiden kanssa käyttäjät voivat olla vuorovaikutuksessa SimpleX Chat -sovellusten kautta — odotamme innolla nähdä, millaisia SimpleX-palveluja voit rakentaa.", + "simplex-unique-overlay-card-4-p-2": "SimpleX-alusta käyttää avoimeen protokollaan ja tarjoaa SDK:n chatbotien luomiseen, mahdollistaen palvelujen toteuttamisen, joiden kanssa käyttäjät voivat olla vuorovaikutuksessa SimpleX Chat -sovellusten kautta — odotamme innolla nähdä, millaisia SimpleX-palveluja voit rakentaa.", "simplex-unique-card-3-p-1": "SimpleX tallentaa kaikki käyttäjätiedot asiakaslaitteille siirrettävässä salatussa tietokannan muodossa — se voidaan siirtää toiseen laitteeseen.", "simplex-unique-card-3-p-2": "Päästä päähän salatut viestit säilytetään väliaikaisesti SimpleX-releay-palvelimilla, kunnes ne vastaanotetaan, minkä jälkeen ne poistetaan pysyvästi.", "we-invite-you-to-join-the-conversation": "Kutsumme sinut mukaan keskusteluun", diff --git a/website/langs/fr.json b/website/langs/fr.json index bd91c36e22..124c7ddc67 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -61,7 +61,7 @@ "simplex-private-card-3-point-2": "L'empreinte du serveur et la fixation des canaux empêchent les attaques MITM et les attaques par rejeu.", "simplex-private-card-3-point-3": "La reprise de connexion est désactivée pour empêcher les attaques de session.", "simplex-private-card-4-point-1": "Pour protéger votre adresse IP, vous pouvez accéder aux serveurs via Tor ou un autre réseau de transport superposé.", - "simplex-private-card-4-point-2": "Pour utiliser SimpleX via Tor, veuillez installer l'application Orbot et activez le proxy SOCKS5 (ou le mode VPN sur iOS).", + "simplex-private-card-4-point-2": "Pour utiliser SimpleX via Tor, veuillez installer l'application Orbot et activez le proxy SOCKS5 (ou le mode VPN sur iOS).", "simplex-private-card-5-point-1": "SimpleX utilise une couverture de contenu pour chaque couche de chiffrement afin de résister aux attaques ciblant la taille des messages.", "simplex-private-card-5-point-2": "Cela rend les messages de différentes tailles identiques pour les serveurs et les observateurs du réseau.", "simplex-private-card-6-point-1": "De nombreux réseaux de communication sont vulnérables aux attaques MITM par des serveurs ou des fournisseurs de réseau.", @@ -95,7 +95,7 @@ "hero-overlay-card-1-p-3": "Vous définissez le ou les serveurs que vous souhaitez utiliser pour recevoir les messages et vos contacts — les serveurs que vous utilisez pour leur envoyer des messages. Chaque conversation est susceptible d'utiliser deux serveurs différents.", "hero-overlay-card-1-p-4": "Cette méthode empêche la fuite de métadonnées des utilisateurs au niveau de l'application. Pour améliorer encore la protection de votre vie privée et protéger votre adresse IP, vous pouvez vous connecter aux serveurs de messagerie via Tor.", "hero-overlay-card-1-p-5": "Seuls les appareils clients stockent les profils des utilisateurs, les contacts et les groupes ; les messages sont envoyés avec un chiffrement de bout en bout à deux couches.", - "hero-overlay-card-1-p-6": "En savoir plus sur le livre blanc de SimpleX.", + "hero-overlay-card-1-p-6": "En savoir plus sur le livre blanc de SimpleX.", "hero-overlay-card-2-p-1": "Lorsque les utilisateurs ont des identités persistantes, même s'il ne s'agit que d'un nombre aléatoire, comme un ID de session, il y a un risque que le fournisseur ou un attaquant puisse observer comment les utilisateurs sont connectés et combien de messages ils envoient.", "hero-overlay-card-2-p-2": "Ils pourraient ensuite corréler ces informations avec les réseaux sociaux publics existants, et déterminer de véritables identités.", "hero-overlay-card-2-p-3": "Même avec les applications les plus privées qui utilisent les services Tor v3, si vous parlez à deux contacts différents via le même profil, ils peuvent prouver qu'ils sont connectés à la même personne.", @@ -109,14 +109,14 @@ "simplex-network-overlay-card-1-li-6": "Les réseaux P2P peuvent être vulnérables aux attaques DRDoS, lorsque les clients peuvent retransmettre et amplifier le trafic, entraînant un déni de service à l’échelle du réseau. Les clients SimpleX ne relaient le trafic que depuis des connexions connues et ne peuvent pas être utilisés par un attaquant pour amplifier le trafic sur l’ensemble du réseau.", "privacy-matters-overlay-card-1-p-1": "De nombreuses grandes entreprises utilisent les informations sur vos relations pour estimer vos revenus, vous vendre des produits dont vous n’avez pas vraiment besoin et déterminer les prix.", "privacy-matters-overlay-card-1-p-2": "Les vendeurs en ligne savent que les personnes à faible revenu sont plus susceptibles d'effectuer des achats urgents. Ils peuvent donc pratiquer des prix plus élevés ou supprimer des remises.", - "privacy-matters-overlay-card-1-p-3": "Certaines entreprises financières et d’assurance utilisent les graphes sociaux pour déterminer les taux d’intérêt et les primes d’assurance. Cela conduit souvent les personnes aux revenus modestes à payer davantage — on parle de « prime de pauvreté ».", + "privacy-matters-overlay-card-1-p-3": "Certaines entreprises financières et d’assurance utilisent les graphes sociaux pour déterminer les taux d’intérêt et les primes d’assurance. Cela conduit souvent les personnes aux revenus modestes à payer davantage — on parle de « prime de pauvreté ».", "privacy-matters-overlay-card-1-p-4": "Le réseau SimpleX protège mieux la confidentialité de vos connexions que toute autre solution, empêchant totalement que votre graphe social soit accessible à des entreprises ou des organisations. Même lorsque les utilisateurs utilisent les serveurs préconfigurés dans les applications SimpleX Chat, les opérateurs des serveurs ne connaissent ni le nombre d’utilisateurs ni leurs connexions.", - "privacy-matters-overlay-card-2-p-1": "Il n’y a pas si longtemps, nous avons vu des élections majeures être manipulées par une société de conseil réputée qui a utilisé nos graphes sociaux pour fausser notre perception du monde réel et manipuler nos votes.", + "privacy-matters-overlay-card-2-p-1": "Il n’y a pas si longtemps, nous avons vu des élections majeures être manipulées par une société de conseil réputée qui a utilisé nos graphes sociaux pour fausser notre perception du monde réel et manipuler nos votes.", "privacy-matters-overlay-card-2-p-2": "Pour être objectif et prendre des décisions indépendantes, vous devez garder le contrôle de votre espace informationnel. Cela n’est possible que si vous utilisez un réseau de communication privé qui n’a pas accès à votre graphe social.", "privacy-matters-overlay-card-2-p-3": "SimpleX est le premier réseau qui ne possède aucun identifiant utilisateur par conception, protégeant ainsi votre graphe de connexions mieux que toute autre solution connue.", "privacy-matters-overlay-card-3-p-1": "Tout le monde devrait se soucier de la confidentialité et de la sécurité de ses communications — des conversations lambda peuvent vous mettre en danger, même si vous n'avez rien à cacher.", - "privacy-matters-overlay-card-3-p-2": "L’une des histoires les plus choquantes est celle de Mohamedou Ould Salahi, relatée dans ses mémoires et dans le film Désigné coupable (The Mauritanian). Il a été détenu au camp de Guantánamo, sans procès, et y a été torturé pendant 15 ans après un appel téléphonique à un proche en Afghanistan, étant soupçonné d’être impliqué dans les attentats du 11 septembre, alors qu’il vivait en Allemagne depuis 10 ans.", - "privacy-matters-overlay-card-3-p-3": "Des personnes ordinaires sont arrêtées pour ce qu’elles partagent en ligne, même via leurs comptes « anonymes », même dans les pays démocratiques.", + "privacy-matters-overlay-card-3-p-2": "L’une des histoires les plus choquantes est celle de Mohamedou Ould Salahi, relatée dans ses mémoires et dans le film Désigné coupable (The Mauritanian). Il a été détenu au camp de Guantánamo, sans procès, et y a été torturé pendant 15 ans après un appel téléphonique à un proche en Afghanistan, étant soupçonné d’être impliqué dans les attentats du 11 septembre, alors qu’il vivait en Allemagne depuis 10 ans.", + "privacy-matters-overlay-card-3-p-3": "Des personnes ordinaires sont arrêtées pour ce qu’elles partagent en ligne, même via leurs comptes « anonymes », même dans les pays démocratiques.", "privacy-matters-overlay-card-3-p-4": "Il ne suffit pas d'utiliser une messagerie chiffrée de bout en bout, nous devrions tous utiliser des messageries qui protègent la vie privée de nos réseaux personnels — les personnes avec lesquelles nous sommes connectés.", "simplex-unique-overlay-card-1-p-1": "Contrairement aux autres réseaux de messagerie, SimpleX n’attribue aucun identifiant aux utilisateurs. Il ne repose ni sur les numéros de téléphone, ni sur les adresses basées sur des domaines (comme les adresses e-mail ou XMPP), ni sur les noms d’utilisateur, les clés publiques ou même les nombres aléatoires pour identifier ses utilisateurs — les opérateurs des serveurs SimpleX ne savent pas combien de personnes utilisent leurs serveurs.", "simplex-unique-overlay-card-1-p-2": "Pour acheminer les messages, SimpleX utilise des adresses anonymes distinctes pour chaque interlocuteur de files de messages unidirectionnelles, séparées pour les messages reçus et envoyés, généralement via des serveurs différents.", @@ -128,8 +128,8 @@ "simplex-unique-overlay-card-3-p-3": "Contrairement aux serveurs de réseaux fédérés (e-mail, XMPP ou Matrix), les serveurs SimpleX ne stockent pas les comptes d'utilisateurs, ils ne font que relayer les messages, protégeant ainsi la vie privée des deux parties.", "simplex-unique-overlay-card-3-p-4": "Il n'y a pas d'identifiant ou de texte chiffré en commun entre le trafic serveur envoyé et reçu - si quelqu'un l'observe, il ne peut pas facilement déterminer qui communique avec qui, même si le TLS est compromis.", "simplex-unique-overlay-card-4-p-1": "Vous pouvez utiliser SimpleX avec vos propres serveurs et continuer à communiquer avec les personnes qui utilisent les serveurs préconfigurés dans les applications.", - "simplex-unique-overlay-card-4-p-2": "Le réseau SimpleX utilise un protocole ouvert et fournit un SDK pour créer des chatbots, permettant de mettre en œuvre des services avec lesquels les utilisateurs peuvent interagir via les applications SimpleX Chat — nous avons hâte de voir quels services SimpleX vous allez créer.", - "simplex-unique-overlay-card-4-p-3": "Si vous envisagez de développer pour le réseau SimpleX, par exemple un chatbot pour les utilisateurs de l’application SimpleX ou d’intégrer la bibliothèque SimpleX Chat à vos applications mobiles, n’hésitez pas à nous contacter pour obtenir des conseils et de l’aide.", + "simplex-unique-overlay-card-4-p-2": "Le réseau SimpleX utilise un protocole ouvert et fournit un SDK pour créer des chatbots, permettant de mettre en œuvre des services avec lesquels les utilisateurs peuvent interagir via les applications SimpleX Chat — nous avons hâte de voir quels services SimpleX vous allez créer.", + "simplex-unique-overlay-card-4-p-3": "Si vous envisagez de développer pour le réseau SimpleX, par exemple un chatbot pour les utilisateurs de l’application SimpleX ou d’intégrer la bibliothèque SimpleX Chat à vos applications mobiles, n’hésitez pas à nous contacter pour obtenir des conseils et de l’aide.", "simplex-unique-card-1-p-1": "SimpleX protège la confidentialité de votre profil, de vos contacts et de vos métadonnées, en les cachant des serveurs du réseau SimpleX et de tout observateur.", "simplex-unique-card-1-p-2": "Contrairement à tout autre réseau de messagerie existant, SimpleX n'a aucun identifiant attribué aux utilisateurs — pas même de nombres aléatoires.", "simplex-unique-card-2-p-1": "Parce que vous n'avez pas d'identifiant ou d'adresse fixe sur le réseau SimpleX, personne ne peut vous contacter sauf si vous partagez une adresse d'utilisateur unique ou temporaire, comme un code QR ou un lien.", diff --git a/website/langs/he.json b/website/langs/he.json index 11dc4a0b4d..8e1fd0aaf5 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -73,7 +73,7 @@ "simplex-private-5-title": "שכבות מרובות של
ריפוד תוכן", "simplex-private-card-3-point-3": "חידוש החיבור מושבת כדי למנוע התקפות הפעלה.", "simplex-private-card-4-point-1": "כדי להגן על כתובת ה-IP שלכם, אתם יכולים לגשת לשרתים דרך Tor או רשת שכבת-על אחרת של תעבורה.", - "simplex-private-card-4-point-2": "כדי להשתמש ב-SimpleX דרך Tor, התקן את אפליקציית Orbot והפעל את SOCKS5 proxy (או VPN ב-iOS).", + "simplex-private-card-4-point-2": "כדי להשתמש ב-SimpleX דרך Tor, התקן את אפליקציית Orbot והפעל את SOCKS5 proxy (או VPN ב-iOS).", "simplex-private-card-7-point-2": "אם הודעה כלשהי תתווסף, תוסר או תשתנה, הנמען יקבל התראה.", "simplex-private-card-9-point-1": "כל תור הודעות מעביר הודעות בכיוון אחד, עם כתובות השליחה והקבלה השונות.", "privacy-matters-1-overlay-1-title": "פרטיות חוסכת לכם כסף", @@ -99,13 +99,13 @@ "privacy-matters-overlay-card-1-p-2": "קמעונאים מקוונים יודעים שאנשים עם הכנסה נמוכה יותר נוטים יותר לבצע רכישות דחופות, ולכן הם עשויים לגבות מחירים גבוהים יותר או להסיר הנחות.", "hero-overlay-card-1-p-2": "כדי להעביר הודעות, במקום מזהי משתמש המשמשים את כל הרשתות האחרות, SimpleX משתמש במזהים זמניים אנונימיים של תורי הודעות, נפרדים לכל אחד מהחיבורים שלך - אין מזהים ארוכי טווח.", "hero-overlay-card-2-p-2": "לאחר מכן הם יוכלו לקשר מידע זה עם הרשתות החברתיות הציבוריות הקיימות, ולקבוע כמה זהויות אמיתיות.", - "privacy-matters-overlay-card-1-p-3": "חלק מחברות פיננסיות וביטוח משתמשות בגרפים חברתיים כדי לקבוע שיעורי ריבית ופרמיות. לעתים קרובות זה גורם לאנשים עם הכנסה נמוכה יותר לשלם יותר — זה ידוע בתור 'פרמיית עוני'.", + "privacy-matters-overlay-card-1-p-3": "חלק מחברות פיננסיות וביטוח משתמשות בגרפים חברתיים כדי לקבוע שיעורי ריבית ופרמיות. לעתים קרובות זה גורם לאנשים עם הכנסה נמוכה יותר לשלם יותר — זה ידוע בתור 'פרמיית עוני'.", "hero-overlay-card-1-p-4": "עיצוב זה מונע דליפה כלשהי של משתמשים' מטא נתונים ברמת האפליקציה. כדי לשפר עוד יותר את הפרטיות ולהגן על כתובת ה-IP שלך, תוכל להתחבר לשרתי הודעות באמצעות Tor.", "hero-overlay-card-2-p-3": "אפילו עם האפליקציות הפרטיות ביותר המשתמשות בשירותי Tor v3, אם אתה מדבר עם שני אנשי קשר שונים דרך אותו פרופיל הם יכולים להוכיח שהם מחוברים לאותו אדם.", "hero-overlay-card-3-p-1": "Trail of Bits היא חברת ייעוץ מובילה בתחום אבטחה וטכנולוגיה שלקוחותיה כוללים ביג טק, סוכנויות ממשלתיות ופרויקטי בלוקצ'יין גדולים.", "hero-overlay-card-1-p-1": "משתמשים רבים שאלו: אם ל-SimpleX אין מזהי משתמש, איך הוא יכול לדעת לאן להעביר הודעות?", "simplex-network-overlay-card-1-li-2": "לעיצוב של SimpleX, בניגוד לרוב רשתות ה-P2P, אין מזהי משתמש גלובלי מכל סוג שהוא, אפילו זמני, והוא משתמש רק במזהים זמניים זוגיים, המספקים אנונימיות טובה יותר והגנה על מטא נתונים.", - "hero-overlay-card-1-p-6": "קרא עוד בסקירה הטכנית של SimpleX.", + "hero-overlay-card-1-p-6": "קרא עוד בסקירה הטכנית של SimpleX.", "simplex-network-overlay-card-1-p-1": "לפרוטוקולי הודעות ואפליקציות P2P יש בעיות שונות שהופכות אותן לפחות אמינות מ-SimpleX, מורכבות יותר לניתוח, ופגיעות למספר סוגי התקפות.", "hero-overlay-card-1-p-3": "אתה מגדיר באילו שרתים להשתמש כדי לקבל את ההודעות, אנשי הקשר שלך — השרתים שבהם אתה משתמש כדי לשלוח את ההודעות אליהם. סביר שכל שיחה תהשתמש בשני שרתים שונים.", "copy-the-command-below-text": "העתיקו את הפקודה למטה והשתמש בה בצ'אט:", @@ -113,7 +113,7 @@ "scan-the-qr-code-with-the-simplex-chat-app": "סרקו את קוד ה-QR באפליקציית SimpleX Chat", "simplex-unique-card-3-p-2": "ההודעות המוצפנות מקצה לקצה מוחזקות באופן זמני בשרתי ממסר של SimpleX עד שמתקבלות, ואז הן נמחקות לצמיתות.", "simplex-unique-overlay-card-3-p-3": "שלא כמו שרתי רשת מאוחדים (דוא\"ל, XMPP או Matrix), שרתי SimpleX אינם מאחסנים חשבונות משתמשים, הם רק מעבירים הודעות, ומגנים על הפרטיות של שני הצדדים.", - "privacy-matters-overlay-card-3-p-2": "אחד הסיפורים המזעזעים ביותר הוא החוויה של מוחמד אולד סלאחי המתוארת בספר הזיכרונות שלו ומוצגת בסרט \"המאוריטני\". הוא הוכנס למתקן המעצר בגואנטנמו, ללא משפט, ועונה שם במשך 15 שנים לאחר שיחת טלפון לקרוב משפחתו באפגניסטן, בחשד שהיה מעורב בפיגועים ב-11 בספטמבר, למרות שחי בגרמניה בעשר השנים הקודמות.", + "privacy-matters-overlay-card-3-p-2": "אחד הסיפורים המזעזעים ביותר הוא החוויה של מוחמד אולד סלאחי המתוארת בספר הזיכרונות שלו ומוצגת בסרט \"המאוריטני\". הוא הוכנס למתקן המעצר בגואנטנמו, ללא משפט, ועונה שם במשך 15 שנים לאחר שיחת טלפון לקרוב משפחתו באפגניסטן, בחשד שהיה מעורב בפיגועים ב-11 בספטמבר, למרות שחי בגרמניה בעשר השנים הקודמות.", "simplex-network-2-desc": "שרתי ממסר SimpleX אינם מאחסנים פרופילי משתמש, אנשי קשר והודעות שנמסרו, אינם מתחברים זה לזה, ואין ספריית שרתים.", "installing-simplex-chat-to-terminal": "התקנת SimpleX Chat למסוף שורת הפקודה", "use-this-command": "השתמשו בפקודה הזו:", @@ -160,13 +160,13 @@ "simplex-unique-overlay-card-3-p-4": "אין מזהים או טקסט מוצפן משותף בין תעבורת שרת שנשלחה ומתקבלת — אם מישהו צופה בזה, הם לא יכולים לקבוע בקלות מי מתקשר עם מי, גם אם ה-TLS נפרץ.", "get-simplex": "הורידו את אפליקציית שולחן העבודה SimpleX", "privacy-matters-overlay-card-3-p-1": "לכולם צריך להיות אכפת מהפרטיות והאבטחה של התקשורת שלהם — שיחות לא מזיקות עלולות להעמיד אתכם בסכנה, גם אם אין לכם מה להסתיר.", - "simplex-unique-overlay-card-4-p-3": "אם אתם שוקלים לפתח עבור רשת SimpleX, למשל, את הצ'אט בוט עבור משתמשי אפליקציית SimpleX, או שילוב של ספריית SimpleX Chat באפליקציות לנייד שלכם, אנא צרו קשר לכל עצה ותמיכה.", + "simplex-unique-overlay-card-4-p-3": "אם אתם שוקלים לפתח עבור רשת SimpleX, למשל, את הצ'אט בוט עבור משתמשי אפליקציית SimpleX, או שילוב של ספריית SimpleX Chat באפליקציות לנייד שלכם, אנא צרו קשר לכל עצה ותמיכה.", "simplex-network-3-header": "רשת SimpleX", "simplex-unique-card-2-p-1": "מכיוון שאין לך מזהה או כתובת קבועה ברשת SimpleX, אף אחד לא יכול ליצור איתך קשר אלא אם כן אתה משתף כתובת משתמש חד-פעמית או זמנית, כקוד QR או קישור.", "join-the-REDDIT-community": "הצטרפו לקהילת REDDIT", - "privacy-matters-overlay-card-3-p-3": "אנשים רגילים נעצרים על מה שהם משתפים באינטרנט, אפילו דרך החשבונות ה'אנונימיים' שלהם, אפילו במדינות דמוקרטיות.", + "privacy-matters-overlay-card-3-p-3": "אנשים רגילים נעצרים על מה שהם משתפים באינטרנט, אפילו דרך החשבונות ה'אנונימיים' שלהם, אפילו במדינות דמוקרטיות.", "simplex-unique-overlay-card-3-p-2": "ההודעות המוצפנות מקצה לקצה מוחזקות באופן זמני בשרתי ממסר של 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 אתם יכולים לבנות.", "contact-hero-p-2": "עדיין לא הורדתם את ה-SimpleX Chat?", "why-simplex-is-unique": "מדוע SimpleX הוא ייחודי", "simplex-network-section-header": "רשת SimpleX", @@ -174,7 +174,7 @@ "simplex-network-1-overlay-linktext": "בעיות של רשתות P2P", "protocol-2-text": "XMPP, Matrix", "simplex-network-overlay-card-1-li-4": "יישומי P2P עלולים להיחסם על ידי ספקי אינטרנט מסוימים (כמו BitTorrent). SimpleX אינו תלוי בתעבורה - הוא יכול לעבוד על פרוטוקולי אינטרנט סטנדרטיים, למשל WebSockets.", - "privacy-matters-overlay-card-2-p-1": "לפני זמן לא רב ראינו את הבחירות הראשיות שעברו מניפולציות על ידי חברת ייעוץ מכובדת שהשתמשה בגרפים החברתיים שלנו כדי לעוות את השקפתנו על העולם האמיתי ולתמרן את ההצבעות שלנו.", + "privacy-matters-overlay-card-2-p-1": "לפני זמן לא רב ראינו את הבחירות הראשיות שעברו מניפולציות על ידי חברת ייעוץ מכובדת שהשתמשה בגרפים החברתיים שלנו כדי לעוות את השקפתנו על העולם האמיתי ולתמרן את ההצבעות שלנו.", "privacy-matters-overlay-card-2-p-3": "SimpleX היא הרשת הראשונה שאין לה מזהי משתמש מטבעה, ובכך מגינה על גרף החיבורים שלך טוב יותר מכל חלופה מוכרת.", "learn-more": "למדו עוד", "scan-qr-code-from-mobile-app": "סרקו קוד QR באפליקציה לנייד", diff --git a/website/langs/hu.json b/website/langs/hu.json index 80a5c614e1..1b3a4f5869 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -90,7 +90,7 @@ "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, partnereket és csoportokat; az üzenetek küldése kétré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-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ő partnerével beszél ugyanazon a profilon keresztül, bizonyítani tudják, hogy ugyanahhoz a személyhez kapcsolódnak.", @@ -104,14 +104,14 @@ "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 a felhasználóival kapcsolatban álló személyek adatait, hogy megbecsülje a jövedelmi helyzetüket és olyan termékeket kínáljon fel, amelyekre valójában nincs is szükségük, valamint 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 kapcsolatai 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 egy 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 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.", @@ -123,8 +123,8 @@ "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-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 senkinek sincs azonosítója vagy állandó címe, ezért senki sem tud kapcsolatba lépni a felhasználókkal, hacsak nem osztanak meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", @@ -224,7 +224,7 @@ "contact-hero-header": "Ez egy meghívó 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-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-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ó", diff --git a/website/langs/id.json b/website/langs/id.json index 2811873b46..b1134bcaff 100644 --- a/website/langs/id.json +++ b/website/langs/id.json @@ -126,7 +126,7 @@ "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-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.", @@ -145,7 +145,7 @@ "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-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.", @@ -236,14 +236,14 @@ "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-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-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-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.", @@ -255,8 +255,8 @@ "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-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
", diff --git a/website/langs/it.json b/website/langs/it.json index a482d23c03..649637b8e2 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -57,7 +57,7 @@ "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.", @@ -67,14 +67,14 @@ "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 diventi 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.", @@ -155,7 +155,7 @@ "developers": "Sviluppatori", "simplex-explained-tab-1-p-1": "Puoi creare contatti e gruppi, ed avere conversazioni bidirezionali, come in qualsiasi altro messenger.", "feature-2-title": "Immagini, video e file
crittografati E2E", - "simplex-private-card-4-point-2": "Per usare SimpleX tramite Tor, installa l'app Orbot e attiva il proxy SOCKS5 (o VPN su iOS).", + "simplex-private-card-4-point-2": "Per usare SimpleX tramite Tor, installa l'app Orbot e attiva il proxy SOCKS5 (o VPN su iOS).", "reference": "Riferimenti", "simplex-explained-tab-3-text": "3. Cosa vedono i server", "chat-bot-example": "Esempio di chat bot", @@ -180,10 +180,10 @@ "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.", "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 usi 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.", @@ -195,7 +195,7 @@ "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:", diff --git a/website/langs/ja.json b/website/langs/ja.json index 087a18ac6a..4c850853e4 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -24,8 +24,8 @@ "guide-dropdown-8": "アプリ設定", "menu": "メニュー", "simplex-unique-card-1-p-1": "SimpleXは、SimpleXネットワークのサーバやその他の観察者から隠すことで、あなたのプロフィール、連絡先やメタデータのプライバシーを守ります。", - "simplex-unique-overlay-card-4-p-3": "例えば、SimpleXアプリユーザへのチャットボットやSimpleX Chatライブラリーの携帯アプリへの統合など、SimpleXネットワークに関する開発を検討してくださっているようでしたら、どのようなアドバイスや支援のことでもご連絡ください 。", - "simplex-unique-overlay-card-4-p-2": "SimpleXネットワークは、SimpleX Chatアプリを介してユーザが交流するサービスを実装させつつオープンプロトコルを使い、チャットボットを作成するためにSDKを提供します—私たちはあなた達がどのようなSimpleXのサービスを築くか本当に楽しみです。", + "simplex-unique-overlay-card-4-p-3": "例えば、SimpleXアプリユーザへのチャットボットやSimpleX Chatライブラリーの携帯アプリへの統合など、SimpleXネットワークに関する開発を検討してくださっているようでしたら、どのようなアドバイスや支援のことでもご連絡ください 。", + "simplex-unique-overlay-card-4-p-2": "SimpleXネットワークは、SimpleX Chatアプリを介してユーザが交流するサービスを実装させつつオープンプロトコルを使い、チャットボットを作成するためにSDKを提供します—私たちはあなた達がどのようなSimpleXのサービスを築くか本当に楽しみです。", "simplex-unique-overlay-card-4-p-1": "あなたが、自分自身のサーバでSimpleXを使っても、アプリで予め設定されたサーバを使う方々と連絡を取ることができます。", "reference": "リファレンス", "simplex-explained-tab-1-text": "1. ユーザーが経験すること", @@ -64,7 +64,7 @@ "feature-7-title": "ポータブルな暗号化データベース — プロファイルを別のデバイスに移動する", "no-federated": "いいえ - 連合型", "simplex-unique-overlay-card-3-p-3": "電子メール、XMPP、Matrixなどの連携ネットワークサーバーとは異なり、SimpleXサーバーはユーザーアカウントを保存せず、メッセージの中継のみを行い、双方のプライバシーを保護します。", - "privacy-matters-overlay-card-3-p-2": "最も衝撃的な話の 1 つは、Mohamedou Ould Salahiの経験であり、彼の回顧録に記述され、『モーリタニア映画』で紹介されています。彼はアフガニスタンの親戚に電話をかけた後、アメリカ同時多発テロへの関与の疑いで拘束されました。 彼は裁判も受けずにグアンタナモ収容所に入れられ、そこで15年間拷問を受けました。", + "privacy-matters-overlay-card-3-p-2": "最も衝撃的な話の 1 つは、Mohamedou Ould Salahiの経験であり、彼の回顧録に記述され、『モーリタニア映画』で紹介されています。彼はアフガニスタンの親戚に電話をかけた後、アメリカ同時多発テロへの関与の疑いで拘束されました。 彼は裁判も受けずにグアンタナモ収容所に入れられ、そこで15年間拷問を受けました。", "signing-key-fingerprint": "署名キーのフィンガープリント (SHA-256)", "simplex-network-2-desc": "SimpleX リレー サーバーは、ユーザー プロファイル、連絡先、配信されたメッセージを保存せず、相互に接続せず、サーバー ディレクトリもありません。", "docs-dropdown-5": "ホストXFTPサーバー", @@ -109,7 +109,7 @@ "no": "いいえ", "contact-hero-header": "SimpleX Chatで接続するためのアドレスを受信しました", "feature-8-title": "シークレット モード —
SimpleX Chat に固有の", - "simplex-private-card-4-point-2": "Tor経由でSimpleXを使用するには、Orbotアプリをインストールし、SOCKS5プロキシを有効にしてください(iOSの場合はVPN)。", + "simplex-private-card-4-point-2": "Tor経由でSimpleXを使用するには、Orbotアプリをインストールし、SOCKS5プロキシを有効にしてください(iOSの場合はVPN)。", "contact-hero-subheader": "スマホ・タブレットのSimpleX ChatアプリでQRコードを読み取ってください。", "simplex-unique-2-overlay-1-title": "スパムと悪用からの最高の保護", "simplex-private-6-title": "帯域外の
鍵交換", @@ -128,7 +128,7 @@ "comparison-point-1-text": "グローバル ID が必要", "comparison-section-list-point-5": "ユーザーのメタデータのプライバシーを保護しない", "hero-overlay-card-2-p-2": "その後、この情報を既存の公開ソーシャル ネットワークと関連付けて、本当の身元を特定することができます。", - "privacy-matters-overlay-card-1-p-3": "一部の金融会社や保険会社は、ソーシャル グラフを使用して金利や保険料を決定しています。 多くの場合、収入の低い人にはより多くの料金を支払わなければなりません。 —これは、\"「貧困プレミアム」\" として知られています。", + "privacy-matters-overlay-card-1-p-3": "一部の金融会社や保険会社は、ソーシャル グラフを使用して金利や保険料を決定しています。 多くの場合、収入の低い人にはより多くの料金を支払わなければなりません。 —これは、\"「貧困プレミアム」\" として知られています。", "comparison-point-3-text": "DNS への依存", "yes": "はい", "docs-dropdown-6": "WebRTC サーバー", @@ -190,7 +190,7 @@ "feature-6-title": "E2E暗号化された
音声通話とビデオ通話", "simplex-network-overlay-card-1-li-2": "SimpleX 設計は、ほとんどの P2P ネットワークとは異なり、一時的であってもいかなる種類のグローバル ユーザー識別子も持たず、一時的なペアごとの識別子のみを使用するため、より優れた匿名性とメタデータ保護が提供されます。", "simplex-unique-4-title": "SimpleX ネットワークを所有", - "privacy-matters-overlay-card-3-p-3": "一般の人が、たとえ「匿名」アカウント経由であっても、オンラインで共有した内容で逮捕されます。それは、たとえ民主主義の国であったとしても同じです。", + "privacy-matters-overlay-card-3-p-3": "一般の人が、たとえ「匿名」アカウント経由であっても、オンラインで共有した内容で逮捕されます。それは、たとえ民主主義の国であったとしても同じです。", "simplex-unique-overlay-card-3-p-2": "エンドツーエンドで暗号化されたメッセージは、SimpleXのリレーサーバーで受信するまで一時的に保持され、その後永久に削除されます。", "simplex-private-card-7-point-1": "整合性を保証するために、メッセージには連続した番号が付けられ、前のメッセージのハッシュが含まれます。", "contact-hero-p-2": "SimpleX Chat をまだダウンロードしていませんか?", @@ -209,7 +209,7 @@ "hero-overlay-2-title": "ユーザー ID がプライバシーに悪影響を与えるのはなぜですか?", "docs-dropdown-4": "ホストSMPサーバー", "feature-4-title": "E2E暗号化された音声メッセージ", - "privacy-matters-overlay-card-2-p-1": "つい最近まで、主要な選挙が 有名なコンサルティング会社によって操作されていました。 ソーシャルグラフは私たちの現実世界の見方を歪め、投票を操作しています。", + "privacy-matters-overlay-card-2-p-1": "つい最近まで、主要な選挙が 有名なコンサルティング会社によって操作されていました。 ソーシャルグラフは私たちの現実世界の見方を歪め、投票を操作しています。", "privacy-matters-overlay-card-2-p-3": "SimpleX は、設計上ユーザー識別子を持たない最初のネットワークであり、この方法で既知の代替手段よりも接続グラフを保護します。", "learn-more": "さらに詳しく", "simplex-private-8-title": "メッセージのミキシング
相関性を減らす", @@ -233,7 +233,7 @@ "simplex-unique-overlay-card-1-p-3": "この設計により、通信相手のプライバシーが保護され、SimpleX ネットワークサーバや監視者からプライバシーが隠されます。 IP アドレスをサーバから隠すには、Tor 経由で SimpleX サーバーに接続します。", "simplex-private-7-title": "メッセージの整合性
検証", "privacy-matters-overlay-card-1-p-4": "SimpleXネットワークは、他のどのプラットフォームよりも接続のプライバシーを保護し、ソーシャル グラフが企業や組織に利用されることを完全に防ぎます。 SimpleX Chatアプリに予め設定されたサーバを利用している場合でも、サーバオペレータはユーザーの数や接続数を知ることはできません。", - "hero-overlay-card-1-p-6": "詳細については、SimpleX ホワイトペーパーをご覧ください。", + "hero-overlay-card-1-p-6": "詳細については、SimpleX ホワイトペーパーをご覧ください。", "simplex-network-overlay-card-1-p-1": "P2P メッセージング プロトコルとアプリには、SimpleX よりも信頼性が低く、分析がより複雑になるさまざまな問題があり、また、いくつかの種類の攻撃に対して脆弱です。", "simplex-network-overlay-card-1-li-1": "P2P ネットワークは、メッセージをルーティングするために DHT の一部の変種に依存します。 DHT の設計では、配信保証と遅延のバランスを取る必要があります。 SimpleX は、受信者が選択したサーバーを使用して、メッセージを複数のサーバーを介して並行して冗長的に渡すことができるため、P2P よりも優れた配信保証と低い遅延の両方を備えています。 P2P ネットワークでは、メッセージはアルゴリズムによって選択されたノードを使用して、O(log N) 個のノードを順番に通過します。", "privacy-matters-section-label": "メッセージアプリがあなたのデータにアクセスできないようにしてください!", diff --git a/website/langs/nl.json b/website/langs/nl.json index aebab52842..a7d3741e27 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -70,7 +70,7 @@ "simplex-private-6-title": "Out-of-band
sleuteluitwisseling", "simplex-private-7-title": "Bericht integriteit
verificatie", "simplex-private-card-4-point-1": "Om uw IP-adres te beschermen kunt u via Tor of een ander transport overlay netwerk toegang krijgen tot de servers.", - "simplex-private-card-4-point-2": "Installeer de Orbot-app en schakel de SOCKS5-proxy in (of VPN op iOS ) om SimpleX via Tor te gebruiken.", + "simplex-private-card-4-point-2": "Installeer de Orbot-app en schakel de SOCKS5-proxy in (of VPN op iOS ) om SimpleX via Tor te gebruiken.", "simplex-private-card-6-point-2": "Om te voorkomen dat SimpleX apps eenmalige sleutels out-of-band doorgeven, wanneer u een adres deelt als een link of een QR-code.", "simplex-private-card-7-point-1": "Om de integriteit te garanderen, zijn de berichten opeenvolgend genummerd en bevatten ze de hash van het vorige bericht.", "simplex-private-card-9-point-1": "Elke berichten wachtrij geeft berichten in één richting door, met verschillende verzend en ontvangst adressen.", @@ -96,7 +96,7 @@ "hero-overlay-card-2-p-1": "Wanneer gebruikers een blijvende identiteit hebben, zelfs als dit slechts een willekeurig nummer is, zoals een sessie ID, bestaat het risico dat de provider of een aanvaller kan zien hoe de gebruikers zijn verbonden en hoeveel berichten ze verzenden.", "hero-overlay-card-1-p-4": "Dit ontwerp voorkomt het lekken van eventuele gebruikersgegevens en metadata op applicatie niveau. Om de privacy verder te verbeteren en uw IP-adres te beschermen kunt u via Tor verbinding maken met berichten servers.", "hero-overlay-card-1-p-5": "Alleen client apparaten slaan gebruikers profielen, contacten en groepen op; de berichten worden verzonden met 2-laags end-to-end versleuteling.", - "hero-overlay-card-1-p-6": "Lees meer in SimpleX-whitepaper.", + "hero-overlay-card-1-p-6": "Lees meer in SimpleX-whitepaper.", "hero-overlay-card-2-p-2": "Ze zouden deze informatie vervolgens kunnen correleren met de bestaande openbare sociale netwerken en een aantal echte identiteiten kunnen bepalen.", "privacy-matters-3-overlay-1-linkText": "Privacy beschermt uw vrijheid", "simplex-unique-2-overlay-1-title": "De beste bescherming tegen spam en misbruik", @@ -178,14 +178,14 @@ "simplex-network-overlay-card-1-li-6": "P2P netwerken kunnen kwetsbaar zijn voor DRDoS-aanvallen, wanneer de clients het verkeer opnieuw kunnen uitzenden en versterken, wat resulteert in netwerkbrede denial of service. SimpleX clients sturen alleen verkeer van een bekende verbinding door en kunnen niet door een aanvaller worden gebruikt om het verkeer in het hele netwerk te versterken.", "privacy-matters-overlay-card-1-p-1": "Veel grote bedrijven gebruiken informatie over met wie u verbonden bent om uw inkomen te schatten, u de producten te verkopen die u niet echt nodig heeft en om de prijzen te bepalen.", "privacy-matters-overlay-card-1-p-2": "Webwinkels weten dat mensen met lagere inkomens eerder geneigd zijn om dringende aankopen te doen, dus kunnen ze hogere prijzen vragen of kortingen intrekken.", - "privacy-matters-overlay-card-1-p-3": "Sommige financiële en verzekeringsmaatschappijen gebruiken sociale grafieken om rentetarieven en premies te bepalen. Het zorgt er vaak voor dat mensen met lagere inkomens meer betalen — het staat bekend als 'armoedepremie'.", + "privacy-matters-overlay-card-1-p-3": "Sommige financiële en verzekeringsmaatschappijen gebruiken sociale grafieken om rentetarieven en premies te bepalen. Het zorgt er vaak voor dat mensen met lagere inkomens meer betalen — het staat bekend als 'armoedepremie'.", "privacy-matters-overlay-card-1-p-4": "SimpleX Network beschermt de privacy van uw verbindingen beter dan welk alternatief dan ook en voorkomt dat uw sociale netwerk beschikbaar wordt voor bedrijven of organisaties. Zelfs wanneer gebruikers servers gebruiken die vooraf zijn geconfigureerd in SimpleX Chat-apps, weten serverbeheerders niet hoeveel gebruikers er zijn of hoeveel verbindingen ze hebben.", "privacy-matters-overlay-card-2-p-2": "Om objectief te zijn en onafhankelijke beslissingen te nemen, moet je de controle hebben over je informatieruimte. Dit is alleen mogelijk als je gebruikmaakt van een privécommunicatienetwerk dat geen toegang heeft tot je sociale media-account.", "privacy-matters-overlay-card-2-p-3": "SimpleX is het eerste netwerk dat standaard geen gebruikers-ID's gebruikt. Hierdoor is uw verbindingsgrafiek beter beschermd dan elk ander bekend alternatief.", "privacy-matters-overlay-card-3-p-1": "Iedereen zou zich zorgen moeten maken over de privacy en veiligheid van hun communicatie — onschuldige gesprekken kunnen je in gevaar brengen, zelfs als je niets te verbergen hebt.", "simplex-unique-overlay-card-1-p-1": "In tegenstelling tot andere berichtennetwerken heeft SimpleX geen identificatiegegevens toegewezen aan de gebruikers. Het maakt geen gebruik van telefoonnummers, domeingebaseerde adressen (zoals e-mail of XMPP), gebruikersnamen, openbare sleutels of zelfs willekeurige getallen om gebruikers te identificeren – SimpleX-serverbeheerders weten niet hoeveel mensen hun servers gebruiken.", - "privacy-matters-overlay-card-3-p-2": "Een van de meest schokkende verhalen is de ervaring van Mohamedou Ould Salahi beschreven in zijn memoires en getoond in The Mauritanian movie. Hij werd zonder proces in het Guantanamo kamp geplaatst en werd daar 15 jaar lang gemarteld na een telefoontje naar zijn familielid in Afghanistan, op verdenking van betrokkenheid bij aanslagen van 9/11, ook al woonde hij de afgelopen 10 jaar in Duitsland.", - "privacy-matters-overlay-card-3-p-3": "Gewone mensen worden gearresteerd voor wat ze online delen, zelfs via hun 'anonieme' accounts, zelfs in democratische landen.", + "privacy-matters-overlay-card-3-p-2": "Een van de meest schokkende verhalen is de ervaring van Mohamedou Ould Salahi beschreven in zijn memoires en getoond in The Mauritanian movie. Hij werd zonder proces in het Guantanamo kamp geplaatst en werd daar 15 jaar lang gemarteld na een telefoontje naar zijn familielid in Afghanistan, op verdenking van betrokkenheid bij aanslagen van 9/11, ook al woonde hij de afgelopen 10 jaar in Duitsland.", + "privacy-matters-overlay-card-3-p-3": "Gewone mensen worden gearresteerd voor wat ze online delen, zelfs via hun 'anonieme' accounts, zelfs in democratische landen.", "privacy-matters-overlay-card-3-p-4": "Het is niet genoeg om een end-to-end versleutelde messenger te gebruiken, we zouden allemaal de messengers moeten gebruiken die de privacy van onze persoonlijke netwerken beschermen — met wie we verbonden zijn.", "simplex-unique-overlay-card-1-p-2": "Om berichten af te leveren gebruikt SimpleX paarsgewijze anonieme adressen van unidirectionele berichten wachtrijen, gescheiden voor ontvangen en verzonden berichten, meestal via verschillende servers.", "simplex-unique-overlay-card-1-p-3": "Dit ontwerp beschermt de privacy van uw communicatiepartner door deze te verbergen voor SimpleX-netwerkservers en andere buitenstaanders. Om uw IP-adres voor de servers te verbergen, kunt u via Tor verbinding maken met SimpleX-servers.", @@ -195,16 +195,16 @@ "simplex-unique-overlay-card-3-p-2": "De end-to-end versleutelde berichten worden tijdelijk op SimpleX relayservers bewaard totdat ze zijn ontvangen, waarna ze permanent worden verwijderd.", "simplex-unique-overlay-card-3-p-3": "In tegenstelling tot gefedereerde netwerkservers (e-mail, XMPP of Matrix), slaan SimpleX servers geen gebruikers account op, ze sturen alleen berichten door, waardoor de privacy van beide partijen wordt beschermd.", "simplex-unique-overlay-card-4-p-1": "U kunt SimpleX gebruiken met uw eigen servers en toch communiceren met mensen die de servers gebruiken die vooraf in de apps zijn geconfigureerd.", - "simplex-unique-overlay-card-4-p-3": "Als u overweegt om voor het SimpleX-netwerk te ontwikkelen, bijvoorbeeld de chatbot voor SimpleX-appgebruikers of de integratie van de SimpleX Chat-bibliotheek in uw mobiele apps, neem dan contact op voor advies en ondersteuning.", + "simplex-unique-overlay-card-4-p-3": "Als u overweegt om voor het SimpleX-netwerk te ontwikkelen, bijvoorbeeld de chatbot voor SimpleX-appgebruikers of de integratie van de SimpleX Chat-bibliotheek in uw mobiele apps, neem dan contact op voor advies en ondersteuning.", "simplex-unique-card-1-p-1": "SimpleX beschermt de privacy van uw profiel, contacten en metagegevens door deze te verbergen voor de netwerkservers van SimpleX en andere buitenstaanders.", "enter-your-email-address": "Voer uw e-mail adres in", "get-simplex": "Verkrijg SimpleX desktop app", "tap-to-close": "Tik om te sluiten", "contact-hero-header": "Je hebt een adres ontvangen om verbinding te maken met SimpleX Chat", "invitation-hero-header": "Je hebt een eenmalige link ontvangen om verbinding te maken met SimpleX Chat", - "privacy-matters-overlay-card-2-p-1": "Nog niet zo lang geleden zagen we dat de grote verkiezingen werden gemanipuleerd door een gerenommeerd adviesbureau dat onze sociale grafieken gebruikte om ons beeld van de echte wereld te vervormen en onze stemmen te manipuleren.", + "privacy-matters-overlay-card-2-p-1": "Nog niet zo lang geleden zagen we dat de grote verkiezingen werden gemanipuleerd door een gerenommeerd adviesbureau dat onze sociale grafieken gebruikte om ons beeld van de echte wereld te vervormen en onze stemmen te manipuleren.", "simplex-unique-overlay-card-3-p-4": "Er zijn geen identifiers of gecodeerde tekst tussen verzonden en ontvangen serververkeer — als iemand het waarneemt, kunnen ze niet gemakkelijk bepalen wie met wie communiceert, zelfs als TLS is gecompromitteerd.", - "simplex-unique-overlay-card-4-p-2": "SimpleX Network maakt gebruik van een open protocol en biedt SDK om chatbots te maken. Dit maakt de implementatie van services mogelijk waarmee gebruikers via SimpleX Chat-apps kunnen communiceren. We zijn erg benieuwd welke SimpleX-services jullie gaan bouwen.", + "simplex-unique-overlay-card-4-p-2": "SimpleX Network maakt gebruik van een open protocol en biedt SDK om chatbots te maken. Dit maakt de implementatie van services mogelijk waarmee gebruikers via SimpleX Chat-apps kunnen communiceren. We zijn erg benieuwd welke SimpleX-services jullie gaan bouwen.", "simplex-unique-card-1-p-2": "In tegenstelling tot andere bestaande berichtennetwerken worden bij SimpleX geen identificatiegegevens aan de gebruikers toegewezen – zelfs geen willekeurige getallen.", "comparison-section-list-point-1": "Meestal op basis van een telefoonnummer, in sommige gevallen op gebruikersnamen", "comparison-section-list-point-2": "Op DNS gebaseerde adressen", diff --git a/website/langs/pl.json b/website/langs/pl.json index 21a3576cb8..a760dc34cf 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -68,7 +68,7 @@ "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.", - "simplex-private-card-4-point-2": "Aby użyć SimpleX przez Tor zainstaluj aplikację Orbot na android i włącz proxy SOCKS5 (lub VPN na iOS).", + "simplex-private-card-4-point-2": "Aby użyć SimpleX przez Tor zainstaluj aplikację Orbot na android i włącz proxy SOCKS5 (lub VPN na iOS).", "simplex-private-card-5-point-1": "SimpleX używa paddingu treści dla każdej warstwy szyfrowania, aby udaremnić ataki na rozmiar wiadomości.", "simplex-private-card-5-point-2": "Dzięki temu wiadomości o różnych rozmiarach wyglądają tak samo dla serwerów i obserwatorów sieci.", "simplex-private-card-8-point-1": "Serwery SimpleX działają jak węzły mieszania o niskim opóźnieniu — wiadomości przychodzące i wychodzące mają inną kolejność.", @@ -115,23 +115,23 @@ "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 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.", + "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.", "hero-overlay-card-2-p-1": "Gdy użytkownicy mają trwałe tożsamości, nawet jeśli jest to tylko losowa liczba, jak ID sesji, istnieje ryzyko, że dostawca lub atakujący może obserwować, jak użytkownicy są połączeni i ile wiadomości wysyłają.", "hero-overlay-card-2-p-2": "Następnie mogliby skorelować te informacje z istniejącymi publicznymi sieciami społecznymi i określić niektóre prawdziwe tożsamości.", "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": "Protokoły P2P 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.", - "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-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": "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 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 graf 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-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-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 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ą.", @@ -149,9 +149,9 @@ "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 prekonfigurowanych w aplikacji serwerów.", "donate-here-to-help-us": "Przekaż darowiznę tutaj, aby nam pomóc", - "simplex-unique-overlay-card-4-p-3": "Jeśli rozważasz rozwój dla sieci SimpleX, na przykład czatbotu dla użytkowników aplikacji SimpleX lub integrację biblioteki SimpleX Chat w swoich aplikacjach mobilnych, prosimy o kontakt dla wszelkich porad i wsparcia.", + "simplex-unique-overlay-card-4-p-3": "Jeśli rozważasz rozwój dla sieci SimpleX, na przykład czatbotu dla użytkowników aplikacji SimpleX lub integrację biblioteki SimpleX Chat w swoich aplikacjach mobilnych, prosimy o kontakt dla wszelkich porad i wsparcia.", "simplex-unique-card-1-p-1": "SimpleX chroni prywatność Twojego profilu, kontaktów i metadanych, ukrywając je przed serwerami sieci SimpleX i wszelkimi obserwatorami.", - "simplex-unique-overlay-card-4-p-2": "Sieć SimpleX wykorzystuje otwarty protokół i udostępnia SDK do tworzenia chatbotów, umożliwiając implementację usług, z którymi użytkownicy mogą wchodzić w interakcje za pośrednictwem aplikacji SimpleX Chat — naprawdę nie możemy się doczekać, aby zobaczyć, jakie usługi SimpleX zbudujesz.", + "simplex-unique-overlay-card-4-p-2": "Sieć SimpleX wykorzystuje otwarty protokół i udostępnia SDK do tworzenia chatbotów, umożliwiając implementację usług, z którymi użytkownicy mogą wchodzić w interakcje za pośrednictwem aplikacji SimpleX Chat — naprawdę nie możemy się doczekać, aby zobaczyć, jakie usługi SimpleX zbudujesz.", "simplex-unique-card-1-p-2": "W przeciwieństwie do każdej innej istniejącej sieci komunikacyjnej, SimpleX nie ma żadnych identyfikatorów przypisanych do użytkowników — nawet losowych liczb.", "tap-the-connect-button-in-the-app": "Stuknij przycisk \"połącz się\" w aplikacji", "join-the-REDDIT-community": "Dołącz do społeczności REDDIT", diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 52848e5cea..4b663e75ec 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -46,7 +46,7 @@ "simplex-private-3-title": "Transporte TLS
seguro e autenticado", "simplex-private-card-2-point-1": "Camada adicional de criptografia de servidor para entrega ao destinatário, para evitar a correlação entre o tráfego de servidor recebido e enviado se o TLS for comprometido.", "simplex-private-card-4-point-1": "Para proteger seu endereço IP, você pode acessar os servidores por meio do Tor ou de alguma outra rede de sobreposição de transporte.", - "simplex-private-card-4-point-2": "Para usar o SimpleX via Tor, instale o aplicativo Orbot e ative o proxy SOCKS5 (ou VPN no iOS).", + "simplex-private-card-4-point-2": "Para usar o SimpleX via Tor, instale o aplicativo Orbot e ative o proxy SOCKS5 (ou VPN no iOS).", "simplex-private-card-7-point-2": "Se alguma mensagem for adicionada, removida ou alterada, o destinatário será alertado.", "simplex-private-card-10-point-1": "O SimpleX usa endereços anônimos temporários em pares e credenciais para cada contato de usuário ou membro de grupo.", "privacy-matters-1-overlay-1-title": "A privacidade economiza seu dinheiro", @@ -68,13 +68,13 @@ "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.", + "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 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 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-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 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-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", @@ -116,17 +116,17 @@ "simplex-unique-4-overlay-1-title": "Totalmente descentralizado — os usuários são proprietários da rede SimpleX", "hero-overlay-card-1-p-3": "Você define o(s) servidor(es) a ser(em) usado(s) para receber as mensagens, seus contatos — os servidores que você usa para enviar as mensagens a eles. É provável que cada conversa use dois servidores diferentes.", "hero-overlay-card-1-p-4": "Esse design evita o vazamento de metadados de qualquer usuário' no nível do aplicativo. Para aumentar ainda mais a privacidade e proteger seu endereço IP, você pode se conectar aos servidores de mensagens via Tor.", - "hero-overlay-card-1-p-6": "Leia mais no informativo técnico do SimpleX.", + "hero-overlay-card-1-p-6": "Leia mais no informativo técnico do SimpleX.", "hero-overlay-card-2-p-2": "Eles poderiam então correlacionar essas informações com as redes sociais públicas existentes e determinar algumas identidades reais.", "hero-overlay-card-2-p-4": "O SimpleX se protege contra esses ataques por não ter nenhum ID de usuário em seu design. E, se você usar o modo Anônimo, terá um nome de exibição diferente para cada contato, evitando qualquer compartilhamento de dados entre eles.", "simplex-network-overlay-card-1-p-1": "Os protocolos e aplicativos de mensagens P2P têm vários problemas que os tornam menos confiáveis do que o SimpleX, mais complexos de analisar e vulneráveis a vários tipos de ataque.", "simplex-network-overlay-card-1-li-2": "O design do SimpleX, ao contrário da maioria das redes P2P, não tem identificadores de usuário globais de qualquer tipo, mesmo temporários, e usa apenas identificadores temporários em pares, proporcionando melhor anonimato e proteção de metadados.", "simplex-network-overlay-card-1-li-3": "O P2P não resolve o problema do ataque MITM, e a maioria das implementações existentes não usa mensagens fora de banda para a troca de chaves inicial. O SimpleX usa mensagens fora de banda ou, em alguns casos, conexões pré-existentes seguras e confiáveis para a troca de chaves inicial.", "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-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 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-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 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.", diff --git a/website/langs/ru.json b/website/langs/ru.json index 9e714e59ff..a8bdfa8725 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -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", @@ -73,7 +73,7 @@ "contact-hero-header": "Вы получили адрес контакта для соединения в SimpleX Chat", "feature-8-title": "Режим инкогнито —
уникальный для SimpleX Chat", "why-simplex": "Почему SimpleX", - "simplex-private-card-4-point-2": "Чтобы использовать SimpleX через сеть Tor, пожалуйста, установите приложение Orbot и включите прокси (или режим VPN на iOS).", + "simplex-private-card-4-point-2": "Чтобы использовать SimpleX через сеть Tor, пожалуйста, установите приложение Orbot и включите прокси (или режим VPN на iOS).", "contact-hero-subheader": "Отсканируйте QR-код с помощью приложения SimpleX Chat на Вашем телефоне или планшете.", "simplex-unique-2-overlay-1-title": "Лучшая защита от спама и злоупотреблений", "simplex-private-6-title": "Обмен ключами
вне протокола", @@ -95,7 +95,7 @@ "comparison-point-1-text": "Требуется глобальный идентификатор", "comparison-section-list-point-5": "Не защищает конфиденциальность пользовательских метаданных", "hero-overlay-card-2-p-2": "Эта информация может быть сопоставлена с социальными сетями, чтобы определить реальные личности пользователей.", - "privacy-matters-overlay-card-1-p-3": "Некоторые финансовые и страховые компании используют социальные сети для определения процентных ставок и премий. Это часто заставляет людей с более низкими доходами платить больше — это известно как \"наценка за бедность\".", + "privacy-matters-overlay-card-1-p-3": "Некоторые финансовые и страховые компании используют социальные сети для определения процентных ставок и премий. Это часто заставляет людей с более низкими доходами платить больше — это известно как \"наценка за бедность\".", "comparison-point-3-text": "Зависимость от DNS", "yes": "Да", "docs-dropdown-6": "Сервера WebRTC", @@ -157,7 +157,7 @@ "get-simplex": "Скачать SimpleX для компьютера", "privacy-matters-overlay-card-3-p-1": "Каждый должен заботиться о конфиденциальности и безопасности своих коммуникаций — безобидные разговоры могут подвергнуть Вас опасности, например за Ваши политические взгляды, даже если кажется, что Вам \"нечего скрывать\".", "simplex-unique-2-title": "Вы защищены от
спама и злоупотреблений", - "simplex-unique-overlay-card-4-p-3": "Если Вы рассматриваете возможность разработки для сети SimpleX, например, чат-бота для пользователей SimpleX или интеграции библиотеки SimpleX Chat в Ваше мобильное приложение, пожалуйста, обращайтесь за любыми советами и поддержкой.", + "simplex-unique-overlay-card-4-p-3": "Если Вы рассматриваете возможность разработки для сети SimpleX, например, чат-бота для пользователей SimpleX или интеграции библиотеки SimpleX Chat в Ваше мобильное приложение, пожалуйста, обращайтесь за любыми советами и поддержкой.", "comparison-section-list-point-2": "Адреса на основе DNS", "stable-and-beta-versions-built-by-developers": "Стабильные и бета-версии, созданные разработчиками", "simplex-network-3-header": "Сеть SimpleX", @@ -180,11 +180,11 @@ "hero-p-1": "Другие приложения используют ID пользователей: Signal, Matrix, Session, Briar, Jami, Cwtch и т. п.
SimpleX не использует, даже случайных цифр.
Это значительно повышает Вашу конфиденциальность.", "simplex-network-overlay-card-1-li-2": "В отличие от многих P2P сетей, SimpleX спроектирован так, чтобы не нуждаться в глобальных идентификаторах пользователей, даже временных, используя только временные попарные идентификаторы, обеспечивая лучшую анонимность и защиту метаданных пользователей.", "simplex-unique-4-title": "Вы владеете
сетью SimpleX", - "privacy-matters-overlay-card-3-p-3": "Обычных людей арестовывают за то, чем они делятся в Интернете, даже через свои \"анонимные\" аккаунты, даже в демократических странах.", + "privacy-matters-overlay-card-3-p-3": "Обычных людей арестовывают за то, чем они делятся в Интернете, даже через свои \"анонимные\" аккаунты, даже в демократических странах.", "simplex-unique-overlay-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", "blog": "Блог", "simplex-private-card-7-point-1": "Для обеспечения неизменности, сообщения нумеруются по порядку и содержат хеш предыдущего сообщения.", - "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-explained-tab-1-p-1": "Вы можете создавать контакты и группы, а также вести двусторонние беседы, как и в любом другом мессенджере.", "contact-hero-p-2": "Еще не скачали SimpleX Chat?", "why-simplex-is-unique": "Почему SimpleX уникален", @@ -203,7 +203,7 @@ "simplex-explained-tab-2-text": "2. Как это работает", "docs-dropdown-4": "Свой SMP Сервер", "feature-4-title": "Голосовые сообщения с E2E-шифрованием", - "privacy-matters-overlay-card-2-p-1": "Не так давно мы наблюдали, как авторитетная консалтинговая компания манипулировала крупными выборами, используя наши контакты в социальных сетях, чтобы изменять наши мнения и манипулировать нашими голосами.", + "privacy-matters-overlay-card-2-p-1": "Не так давно мы наблюдали, как авторитетная консалтинговая компания манипулировала крупными выборами, используя наши контакты в социальных сетях, чтобы изменять наши мнения и манипулировать нашими голосами.", "privacy-matters-overlay-card-2-p-3": "SimpleX — это первая сеть, которая не имеет никаких идентификаторов пользователей, таким образом защищая Ваши контакты лучше, чем любая известная альтернатива.", "learn-more": "Узнать больше", "donate": "Пожертвовать", @@ -234,7 +234,7 @@ "developers": "Разработчики", "simplex-private-7-title": "Проверка неизменности
сообщений", "privacy-matters-overlay-card-1-p-4": "Сеть SimpleX обеспечивает более высокий уровень конфиденциальности ваших связей, чем любое другое решение, полностью предотвращая доступ компаний и организаций к вашему социальному графу. Даже если пользователи используют серверы, предварительно настроенные в приложениях SimpleX Chat, операторы этих серверов не знают ни количества пользователей, ни их связей.", - "hero-overlay-card-1-p-6": "Подробнее читайте в техническом описании SimpleX.", + "hero-overlay-card-1-p-6": "Подробнее читайте в техническом описании SimpleX.", "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", "terms-and-privacy-policy": "Политика Конфиденциальности", "about-and-contact-us": "O нас и наши контакты", diff --git a/website/langs/tr.json b/website/langs/tr.json index de607f689a..216e03b43c 100644 --- a/website/langs/tr.json +++ b/website/langs/tr.json @@ -67,7 +67,7 @@ "simplex-private-card-3-point-2": "Sunucu parmak izleri ve kanal bağlama, MITM ve tekrar saldırılarını önler.", "simplex-private-card-3-point-3": "Oturum saldırılarını önlemek için bağlantı devamı devre dışı bırakılmıştır.", "simplex-private-card-4-point-1": "IP adresinizi korumak için Tor veya başka bir taşıma katmanı ağı üzerinden sunuculara erişebilirsiniz.", - "simplex-private-card-4-point-2": "Android'de Tor ile SimpleX kullanmak için Orbot uygulamasını yükleyin ve SOCKS5 proxy'yi etkinleştirin veya iOS'ta VPN kullanın.", + "simplex-private-card-4-point-2": "Android'de Tor ile SimpleX kullanmak için Orbot uygulamasını yükleyin ve SOCKS5 proxy'yi etkinleştirin veya iOS'ta VPN kullanın.", "simplex-private-card-5-point-1": "SimpleX, mesaj boyutu saldırılarını önlemek için her şifreleme katmanında içerik doldurma kullanır.", "simplex-private-card-5-point-2": "Sunucu ve ağ gözlemcileri için aynı görünen farklı boyutlarda mesajlar üretir.", "simplex-private-card-6-point-1": "Birçok iletişim ağı, sunucu veya ağ sağlayıcıları tarafından yapılan MITM saldırılarına karşı savunmasızdır.", @@ -101,7 +101,7 @@ "hero-overlay-card-1-p-3": "Hangi sunucuları mesaj almak için kullanacağınızı siz belirlersiniz. Kişileriniz — bu sunucuları size mesaj göndermek için kullanır. Her konuşma genellikle iki farklı sunucu kullanır.", "hero-overlay-card-1-p-4": "Bu tasarım, uygulama düzeyinde kullanıcı meta verilerinin sızmasını önler. Mesaj sunucularına Tor üzerinden bağlanarak gizliliğinizi ve IP adresinizi koruyabilirsiniz.", "hero-overlay-card-1-p-5": "Kullanıcı profilleri, kişiler ve gruplar yalnızca kullanıcının cihazında saklanır. Mesajlar, iki katmanlı uçtan uca şifreleme ile gönderilir.", - "hero-overlay-card-1-p-6": "Daha fazla bilgi için SimpleX teknik dokümanını okuyun.", + "hero-overlay-card-1-p-6": "Daha fazla bilgi için SimpleX teknik dokümanını okuyun.", "hero-overlay-card-2-p-1": "Kullanıcıların kalıcı kimlikleri olduğunda, bu bir oturum kimliği gibi rastgele bir sayı olsa bile, sağlayıcıların veya saldırganların kullanıcıların birbirleriyle nasıl bağlantılı olduğunu ve kaç mesaj gönderdiklerini tespit etme riski vardır.", "hero-overlay-card-2-p-2": "Bu bilgiler, mevcut halka açık sosyal ağlarla ilişkilendirilebilir ve gerçek kimlikler ortaya çıkarılabilir.", "hero-overlay-card-2-p-3": "Aynı profil üzerinden iki farklı kişiyle sohbet ederseniz, en gizlilik odaklı uygulamalarda bile (ör. Tor v3 hizmetleri kullananlar) bu kişiler, aynı kişiyle bağlantılı olduklarını anlayabilir.", @@ -118,14 +118,14 @@ "simplex-network-overlay-card-1-li-6": "P2P ağları, istemciler trafiği tekrar gönderip güçlendirebildiğinde DRDoS saldırılarına karşı savunmasız olabilir ve bu da tüm ağda hizmet reddine yol açabilir. SimpleX istemcileri yalnızca bilinen bağlantılardan gelen trafiği iletir ve bir saldırgan tarafından ağ genelinde trafiği güçlendirmek için kullanılamaz.", "privacy-matters-overlay-card-1-p-1": "Birçok büyük şirket, kiminle bağlantılı olduğunuz bilgisini gelir seviyenizi tahmin etmek, size gerçekten ihtiyacınız olmayan ürünleri satmak ve fiyatları belirlemek için kullanır.", "privacy-matters-overlay-card-1-p-2": "Çevrimiçi satıcılar, daha düşük gelire sahip kişilerin daha acil alışverişler yaptığını bilir ve bu nedenle daha yüksek fiyatlar talep edebilir veya indirimleri kaldırabilir.", - "privacy-matters-overlay-card-1-p-3": "Bazı finans ve sigorta şirketleri, faiz oranlarını ve primleri belirlemek için sosyal grafikleri kullanır. Düşük gelirliler genellikle daha fazla öder — bu, \"yoksulluk primi\" olarak bilinir.", + "privacy-matters-overlay-card-1-p-3": "Bazı finans ve sigorta şirketleri, faiz oranlarını ve primleri belirlemek için sosyal grafikleri kullanır. Düşük gelirliler genellikle daha fazla öder — bu, \"yoksulluk primi\" olarak bilinir.", "privacy-matters-overlay-card-1-p-4": "SimpleX ağı, bağlantılarınızın gizliliğini diğer tüm alternatiflerden daha iyi korur ve sosyal grafiğinizin şirketler veya kuruluşlar tarafından erişilmesini tamamen engeller. Kullanıcılar SimpleX Chat uygulamasının önceden yapılandırılmış sunucularını kullansa bile, sunucu sahipleri kullanıcı sayısını veya bağlantılarını bilmez.", - "privacy-matters-overlay-card-2-p-1": "Çok da uzun olmayan bir süre önce, büyük seçimlerin saygın bir danışmanlık şirketi tarafından sosyal grafiklerimiz kullanılarak manipüle edildiğine tanık olduk; bu, gerçek dünyaya bakışımızı çarpıttı ve oylarımızı etkiledi.", + "privacy-matters-overlay-card-2-p-1": "Çok da uzun olmayan bir süre önce, büyük seçimlerin saygın bir danışmanlık şirketi tarafından sosyal grafiklerimiz kullanılarak manipüle edildiğine tanık olduk; bu, gerçek dünyaya bakışımızı çarpıttı ve oylarımızı etkiledi.", "privacy-matters-overlay-card-2-p-2": "Tarafsız olmak ve bağımsız kararlar verebilmek için bilgi alanınız üzerinde kontrol sahibi olmalısınız. Bu, yalnızca sosyal grafiğinize erişimi olmayan özel bir iletişim ağı kullanıyorsanız mümkündür.", "privacy-matters-overlay-card-2-p-3": "SimpleX, tasarımı gereği hiçbir kullanıcı kimliği olmayan ilk ağdır ve bağlantı grafiğinizi diğer tüm bilinen alternatiflerden daha iyi korur.", "privacy-matters-overlay-card-3-p-1": "Herkes gizliliğine ve iletişiminin güvenliğine önem vermelidir — zararsız sohbetler bile, saklayacak bir şeyiniz olmasa da sizi tehlikeye atabilir.", - "privacy-matters-overlay-card-3-p-2": "En çarpıcı hikayelerden biri, Mohamedou Ould Slahi'nin yaşadıklarıdır; anılarını yazdığı kitapta ve \"The Mauritanian\" filminde anlatılır. Afganistan'daki akrabalarını aradıktan sonra, Almanya'da 10 yıl yaşamasına rağmen, Guantanamo'ya gönderildi ve yıllarca yargılanmadan işkence gördü çünkü 11 Eylül saldırılarına karıştığından şüpheleniliyordu.", - "privacy-matters-overlay-card-3-p-3": "Normal insanlar bile, çevrimiçi olarak paylaştıkları şeyler nedeniyle, hatta \"anonim\" hesaplarını kullansalar bile, demokratik ülkelerde bile tutuklanabiliyor.", + "privacy-matters-overlay-card-3-p-2": "En çarpıcı hikayelerden biri, Mohamedou Ould Slahi'nin yaşadıklarıdır; anılarını yazdığı kitapta ve \"The Mauritanian\" filminde anlatılır. Afganistan'daki akrabalarını aradıktan sonra, Almanya'da 10 yıl yaşamasına rağmen, Guantanamo'ya gönderildi ve yıllarca yargılanmadan işkence gördü çünkü 11 Eylül saldırılarına karıştığından şüpheleniliyordu.", + "privacy-matters-overlay-card-3-p-3": "Normal insanlar bile, çevrimiçi olarak paylaştıkları şeyler nedeniyle, hatta \"anonim\" hesaplarını kullansalar bile, demokratik ülkelerde bile tutuklanabiliyor.", "privacy-matters-overlay-card-3-p-4": "Sadece uçtan uca şifreli bir mesajlaşma uygulaması kullanmak yeterli değildir. Hepimiz, bağlı olduğumuz kişisel ağlarımızın gizliliğini koruyan bir mesajlaşma uygulaması kullanmalıyız.", "simplex-unique-overlay-card-1-p-1": "Diğer mesajlaşma ağlarının aksine, SimpleX kullanıcılara hiçbir kimlik atamaz. Kullanıcıları tanımlamak için telefon numaralarına, alan adı tabanlı adreslere (e-posta veya XMPP gibi), kullanıcı adlarına, genel anahtarlara veya rastgele sayılara dayanmaz — SimpleX sunucu sahipleri bile sunucularını kaç kişinin kullandığını bilmez.", "simplex-unique-overlay-card-1-p-2": "SimpleX, mesajları iletmek için ikili anonim adresler kullanır; bunlar tek yönlü mesaj kuyruklarından oluşur ve alınan ve gönderilen mesajlar için ayrıdır, genellikle farklı sunucular üzerinden iletilir.", @@ -137,8 +137,8 @@ "simplex-unique-overlay-card-3-p-3": "Federasyonlu ağ sunucularının (ör. e-posta, XMPP veya Matrix) aksine, SimpleX sunucuları kullanıcı hesabı saklamaz, yalnızca mesajları iletir ve böylece tarafların gizliliği korunur.", "simplex-unique-overlay-card-3-p-4": "Gönderilen ve alınan sunucu trafiği arasında ortak bir kimlik veya şifreli metin yoktur — bu nedenle, bir gözlemci TLS ele geçirilse bile kimin kiminle iletişim kurduğunu kolayca tespit edemez.", "simplex-unique-overlay-card-4-p-1": "Kendi sunucularınızla SimpleX kullanabilirsiniz ve yine de uygulamanın önceden yapılandırılmış sunucularını kullanan kişilerle iletişim kurabilirsiniz.", - "simplex-unique-overlay-card-4-p-2": "SimpleX ağı açık bir protokol kullanır ve sohbet botları geliştirmek için bir SDK sunar. Bu sayede kullanıcıların SimpleX Chat uygulamalarıyla etkileşime geçebileceği servisler oluşturabilirsiniz — hangi SimpleX servislerini geliştireceğinizi merakla bekliyoruz.", - "simplex-unique-overlay-card-4-p-3": "SimpleX ağının geliştirilmesine aktif olarak katkıda bulunmak, örneğin SimpleX uygulama kullanıcıları için bir sohbet botu geliştirmek veya SimpleX Chat kütüphanesinin mobil uygulamalara entegrasyonunu ilerletmek isterseniz, bizimle iletişime geçin ve destek alın.", + "simplex-unique-overlay-card-4-p-2": "SimpleX ağı açık bir protokol kullanır ve sohbet botları geliştirmek için bir SDK sunar. Bu sayede kullanıcıların SimpleX Chat uygulamalarıyla etkileşime geçebileceği servisler oluşturabilirsiniz — hangi SimpleX servislerini geliştireceğinizi merakla bekliyoruz.", + "simplex-unique-overlay-card-4-p-3": "SimpleX ağının geliştirilmesine aktif olarak katkıda bulunmak, örneğin SimpleX uygulama kullanıcıları için bir sohbet botu geliştirmek veya SimpleX Chat kütüphanesinin mobil uygulamalara entegrasyonunu ilerletmek isterseniz, bizimle iletişime geçin ve destek alın.", "simplex-unique-card-1-p-1": "SimpleX, profilinizin, kişilerin ve meta verilerinizin gizliliğini korur ve bunları SimpleX ağ sunucularından ve diğer olası gözlemcilerden gizler.", "simplex-unique-card-1-p-2": "Mevcut tüm diğer mesajlaşma ağlarının aksine, SimpleX kullanıcılarına hiçbir kimlik atamaz — rastgele sayılar bile yoktur.", "simplex-unique-card-2-p-1": "SimpleX ağında kimliğiniz veya sabit bir adresiniz olmadığı için, kimse size ulaşamaz; yalnızca tek kullanımlık veya geçici bir kullanıcı adresini QR kodu veya bağlantı olarak paylaşırsanız iletişim kurabilir.", diff --git a/website/langs/uk.json b/website/langs/uk.json index 1848b5ce96..d681517200 100644 --- a/website/langs/uk.json +++ b/website/langs/uk.json @@ -13,13 +13,13 @@ "simplex-network-overlay-card-1-li-5": "Всі відомі P2P-мережі можуть бути вразливими до атаки Sybil, оскільки кожен вузол є відкритим, і мережа працює як ціле. Відомі заходи для пом'якшення цієї атаки вимагають або централізованого компонента, або дорогого доказу роботи. Мережа SimpleX не має виявності сервера, вона роздроблена і працює як кілька ізольованих підмереж, що робить неможливими атаки на всю мережу.", "simplex-network-overlay-card-1-li-6": "P2P-мережі можуть бути вразливими до атаки DRDoS, коли клієнти можуть ребродкастити та збільшувати трафік, що призводить до відмови в обслуговуванні на рівні всієї мережі. Клієнти 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-1": "Кожен повинен турбуватися про конфіденційність та безпеку свого спілкування — безпечні розмови можуть поставити вас під загрозу, навіть якщо у вас немає чого приховувати.", - "privacy-matters-overlay-card-3-p-2": "Однією з найшокуючих історій є досвід Мохамеду Ульд Слахі, описаний в його мемуарах і показаний у фільмі \"Мавританець\". Його посадили в табір Гуантанамо без суду і мукили там 15 років після телефонного дзвінка йому родичеві в Афганістані, під підозрою в причетності до атак 11 вересня, навіть не дивлячись на те, що він проживав у Німеччині протягом попередніх 10 років.", + "privacy-matters-overlay-card-3-p-2": "Однією з найшокуючих історій є досвід Мохамеду Ульд Слахі, описаний в його мемуарах і показаний у фільмі \"Мавританець\". Його посадили в табір Гуантанамо без суду і мукили там 15 років після телефонного дзвінка йому родичеві в Афганістані, під підозрою в причетності до атак 11 вересня, навіть не дивлячись на те, що він проживав у Німеччині протягом попередніх 10 років.", "privacy-matters-overlay-card-3-p-4": "Недостатньо використовувати зашифрований від кінця до кінця месенджер; ми всі повинні використовувати месенджери, які захищають конфіденційність наших особистих мереж — з ким ми з'єднані.", "simplex-unique-overlay-card-1-p-1": "На відміну від інших мереж для обміну повідомленнями, у SimpleX немає ідентифікаторів, призначених користувачам. Він не покладається на номери телефонів, адреси на основі доменів (як електронна пошта або XMPP), імена користувачів, публічні ключі або навіть випадкові числа для ідентифікації своїх користувачів — оператори серверів SimpleX не знають, скільки людей використовують їхні сервери.", "simplex-unique-overlay-card-3-p-3": "На відміну від федеративних мереж серверів (електронна пошта, XMPP або Matrix), сервери SimpleX не зберігають облікові записи користувачів, вони лише ретранслюють повідомлення, захищаючи приватність обох сторін.", @@ -118,7 +118,7 @@ "simplex-private-card-3-point-1": "Використовується лише TLS 1.2/1.3 із сильними алгоритмами для з'єднань клієнт-сервер.", "simplex-private-card-3-point-2": "Відбиток сервера та зв'язування каналу запобігають MITM та атакам повтору.", "simplex-private-card-4-point-1": "Для захисту вашої IP-адреси ви можете отримати доступ до серверів через Tor чи іншу транспортну оверлейну мережу.", - "simplex-private-card-4-point-2": "Для використання SimpleX через Tor, будь ласка, встановіть додаток Orbot та активуйте SOCKS5-проксі (або VPN на iOS).", + "simplex-private-card-4-point-2": "Для використання SimpleX через Tor, будь ласка, встановіть додаток Orbot та активуйте SOCKS5-проксі (або VPN на iOS).", "simplex-private-card-5-point-1": "SimpleX використовує наповнення вмісту для кожного шару шифрування для ускладнення атак за розміром повідомлень.", "simplex-private-card-5-point-2": "Це забезпечує, що повідомлення різних розмірів виглядають однаково для серверів та спостерігачів мережі.", "simplex-private-card-6-point-1": "Багато комунікаційних мереж вразливі до MITM-атак серверів чи постачальників мережі.", @@ -148,7 +148,7 @@ "hero-overlay-card-1-p-2": "Для доставки повідомлень, замість ідентифікаторів користувачів, які використовують усі інші мережі, SimpleX використовує тимчасові анонімні парні ідентифікатори черг повідомлень, окремі для кожного вашого підключення — тут немає довгострокових ідентифікаторів.", "hero-overlay-card-1-p-3": "Ви визначаєте, який(кі) сервер(и) використовувати для отримання повідомлень, ваші контакти — сервери, які ви використовуєте для відправки повідомлень їм. Кожна розмова, ймовірно, використовує два різних сервери.", "hero-overlay-card-1-p-4": "Цей дизайн запобігає витоку метаданих будь-яких користувачів на рівні додатка. Для подальшого покращення конфіденційності та захисту вашої IP-адреси ви можете підключитися до серверів обміну повідомленнями через Tor.", - "hero-overlay-card-1-p-6": "Докладніше читайте у білетному запису SimpleX.", + "hero-overlay-card-1-p-6": "Докладніше читайте у білетному запису SimpleX.", "hero-overlay-card-2-p-2": "Вони можуть потім корелювати цю інформацію з існуючими громадськими соціальними мережами та визначати деякі реальні ідентифікатори.", "hero-overlay-card-2-p-3": "Навіть з найбільш приватними додатками, які використовують служби Tor v3, якщо ви розмовляєте з двома різними контактами через той самий профіль, вони можуть довести, що вони підключені до однієї й тієї ж особи.", "hero-overlay-card-2-p-4": "SimpleX захищає від цих атак, не маючи жодних ідентифікаторів користувачів в своєму дизайні. І, якщо ви використовуєте режим інкогніто, у вас буде різне відображення для кожного контакту, уникнення будь-яких спільних даних між ними.", @@ -156,7 +156,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-мереж, немає жодних глобальних ідентифікаторів користувачів будь-якого виду, навіть тимчасових, та використовуються лише тимчасові парні ідентифікатори, що забезпечує кращу анонімність та захист метаданих.", "privacy-matters-overlay-card-1-p-1": "Багато великих компаній використовують інформацію про те, з ким ви з'єднані, щоб оцінити ваш дохід, продавати вам продукти, які вам дійсно не потрібні, і визначати ціни.", - "privacy-matters-overlay-card-3-p-3": "Звичайних людей арештовують за те, що вони публікують онлайн, навіть через свої 'анонімні' облікові записи, навіть у демократичних країнах.", + "privacy-matters-overlay-card-3-p-3": "Звичайних людей арештовують за те, що вони публікують онлайн, навіть через свої 'анонімні' облікові записи, навіть у демократичних країнах.", "simplex-unique-overlay-card-1-p-2": "Для доставки повідомлень SimpleX використовує парні анонімні адреси однобічних черг повідомлень, окремо для отриманих та відправлених повідомлень, зазвичай через різні сервери.", "simplex-unique-overlay-card-1-p-3": "Цей дизайн захищає конфіденційність того, з ким ви спілкуєтесь, приховуючи це від серверів мережі SimpleX та від будь-яких спостерігачів. Щоб приховати вашу IP-адресу від серверів, ви можете підключатися до серверів SimpleX через Tor.", "simplex-unique-overlay-card-2-p-1": "Оскільки у вас немає ідентифікатора в мережі SimpleX, ніхто не може зв'язатися з вами, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, як QR-кодом або посиланням.", @@ -165,8 +165,8 @@ "simplex-unique-overlay-card-3-p-2": "Зашифровані повідомлення від кінця до кінця тимчасово зберігаються на ретрансляційних серверах SimpleX до їх отримання, після чого вони назавжди видаляються.", "simplex-unique-overlay-card-3-p-4": "Між надісланим і отриманим серверним трафіком немає спільних ідентифікаторів чи шифрованого тексту — якщо хтось його спостерігає, він не може легко визначити, хто спілкується з ким, навіть якщо TLS скомпрометовано.", "simplex-unique-overlay-card-4-p-1": "Ви можете використовувати SimpleX зі своїми власними серверами і при цьому спілкуватися з людьми, які використовують сервери, попередньо налаштовані в додатках.", - "simplex-unique-overlay-card-4-p-2": "Мережа SimpleX використовує відкритий протокол і надає SDK для створення чат-ботів, що дозволяє реалізувати сервіси, з якими користувачі можуть взаємодіяти через додатки SimpleX Chat — ми справді чекаємо, щоб побачити, які сервіси SimpleX ви створите.", - "simplex-unique-overlay-card-4-p-3": "Якщо ви плануєте розробляти для мережі SimpleX, наприклад, чат-бота для користувачів додатка SimpleX або інтеграцію бібліотеки SimpleX Chat у ваші мобільні додатки, будь ласка, зв'яжіться з нами для отримання порад та підтримки.", + "simplex-unique-overlay-card-4-p-2": "Мережа SimpleX використовує відкритий протокол і надає SDK для створення чат-ботів, що дозволяє реалізувати сервіси, з якими користувачі можуть взаємодіяти через додатки SimpleX Chat — ми справді чекаємо, щоб побачити, які сервіси SimpleX ви створите.", + "simplex-unique-overlay-card-4-p-3": "Якщо ви плануєте розробляти для мережі SimpleX, наприклад, чат-бота для користувачів додатка SimpleX або інтеграцію бібліотеки SimpleX Chat у ваші мобільні додатки, будь ласка, зв'яжіться з нами для отримання порад та підтримки.", "simplex-unique-card-1-p-1": "SimpleX захищає конфіденційність вашого профілю, контактів та метаданих, приховуючи їх від серверів мережі SimpleX та будь-яких спостерігачів.", "simplex-unique-card-1-p-2": "На відміну від будь-якої іншої існуючої мережі для обміну повідомленнями, у SimpleX немає ідентифікаторів, призначених користувачам — не навіть випадкових чисел.", "simplex-unique-card-2-p-1": "Оскільки у вас немає ідентифікатора або фіксованої адреси в мережі SimpleX, ніхто не може зв'язатися з вами, якщо ви не поділитеся одноразовою або тимчасовою адресою користувача, як QR-кодом або посиланням.", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index 2ead3da487..45cee06f5a 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -86,7 +86,7 @@ "simplex-private-card-3-point-1": "客户端与服务器之间的连接只使用强加密的 TLS 1.2/1.3。", "privacy-matters-3-overlay-1-title": "隐私保您自由", "privacy-matters-3-overlay-1-linkText": "隐私保您自由", - "simplex-private-card-4-point-2": "要通过 Tor 使用 SimpleX,请安装 Orbot 应用并启用 SOCKS5 代理(在 iOS上需要使用 VPN 模式)。", + "simplex-private-card-4-point-2": "要通过 Tor 使用 SimpleX,请安装 Orbot 应用并启用 SOCKS5 代理(在 iOS上需要使用 VPN 模式)。", "simplex-unique-1-overlay-1-title": "您的身份、个人资料、联系人和元数据的完整隐私", "simplex-unique-2-title": "您可以免受垃圾消息和平台滥用的侵害", "simplex-unique-1-title": "您有完整的隐私", @@ -110,7 +110,7 @@ "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 白皮书了解更多信息。", + "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 服务的最私密的应用程序,如果您通过同一个人资料与两个不同的联系人交谈,他们也可以证明他们与同一个人有联系。", @@ -121,14 +121,14 @@ "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 使用单向消息队列的成对匿名地址,通常通过不同的服务器将接收和发送的消息分开。", @@ -136,7 +136,7 @@ "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 以便携式加密数据库格式将所有用户数据存储在客户端设备上—— 它可以转移到另一个设备。", @@ -202,7 +202,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": "在这里捐款来帮助我们", diff --git a/website/langs/zh_Hant.json b/website/langs/zh_Hant.json index ced46c6b69..bf7403e053 100644 --- a/website/langs/zh_Hant.json +++ b/website/langs/zh_Hant.json @@ -69,12 +69,12 @@ "see-here": "見此", "privacy-matters-overlay-card-3-p-4": "僅使用端對端加密的通訊工具是不夠的,我們都應使用能保護個人網路隱私的通訊工具。", "hero-overlay-card-1-p-1": "很多用戶問: 如果 SimpleX 沒有用戶標識符,它何以知道向何處傳送訊息?", - "simplex-unique-overlay-card-4-p-2": "SimpleX網路使用開源協議並提供開發包以編寫聊天機器人,允許使用者透過SimpleX Chat應用程式與服務互動—我們非常期待看到您的SimpleX服務之作!", + "simplex-unique-overlay-card-4-p-2": "SimpleX網路使用開源協議並提供開發包以編寫聊天機器人,允許使用者透過SimpleX Chat應用程式與服務互動—我們非常期待看到您的SimpleX服務之作!", "hero-overlay-card-1-p-2": "為傳送訊息,SimpleX 使用消息佇列中的臨時匿名標識符對,每連線各異,而不像其他網路一樣使用用戶ID— SimpleX 沒有長期標識符。", - "privacy-matters-overlay-card-3-p-2": "最令人震驚的故事之一是 Mohamedou Ould Salahi 的經歷,描述於他的回憶錄中,並在《The Mauritanian movie》中呈現。他在未經審訊的情況下被送進關塔納摩集中營,並在那裏遭受了長達 15 年的酷刑,原因是他被懷疑參與了 9/11 襲擊事件,而此前 10 年他一直住在德國。", + "privacy-matters-overlay-card-3-p-2": "最令人震驚的故事之一是 Mohamedou Ould Salahi 的經歷,描述於他的回憶錄中,並在《The Mauritanian movie》中呈現。他在未經審訊的情況下被送進關塔納摩集中營,並在那裏遭受了長達 15 年的酷刑,原因是他被懷疑參與了 9/11 襲擊事件,而此前 10 年他一直住在德國。", "simplex-private-2-title": "伺服器附加加密層", "hero-overlay-card-2-p-1": "如使用者擁有持久性身份,即使只是一個隨機數,例如 Session ID,也會有服務提供者或攻擊者可以觀察使用者如何連線以及傳送了多少條訊息的風險。", - "simplex-unique-overlay-card-4-p-3": "如您正考慮針對 SimpleX 網路進行開發,例如針對 SimpleX 使用者的聊天機器人,或將 SimpleX 聊天函式庫整合至您的手機應用程式,請與我們聯絡以獲取支持與建議。", + "simplex-unique-overlay-card-4-p-3": "如您正考慮針對 SimpleX 網路進行開發,例如針對 SimpleX 使用者的聊天機器人,或將 SimpleX 聊天函式庫整合至您的手機應用程式,請與我們聯絡以獲取支持與建議。", "simplex-network-overlay-card-1-li-1": "點對點網路依賴 DHT 的某些變體來路由訊息。DHT 設計必須平衡傳送保證和延遲。與點對點相比,SimpleX 具有更好的傳送保證和更低的延遲,因為訊息可以使用收件者選擇的伺服器,經由多個伺服器並行冗餘地傳送。在點對點網路中,訊息依次經由O(log N)個由演算法選擇的節點。", "simplex-private-card-3-point-3": "連線恢復被停用,以防止會話攻擊。", "simplex-private-card-2-point-1": "傳送至收件者的伺服器加密附加層,以防止 TLS 遭到攻擊時,接收和傳送的伺服器流量之間的關聯。", @@ -127,7 +127,7 @@ "simplex-unique-3-overlay-1-title": "您的數據的擁有權、控制與安全", "hero-overlay-card-2-p-4": "SimpleX 通過在設計中不使用任何使用者 ID以防止這些攻擊。而且,如果您使用匿名聊天模式,每個聯絡人都會有不同的顯示名稱,避免他們之間有任何共用資料。", "hero-overlay-card-2-p-3": "即使是使用 Tor v3 服務的最隱私的應用程式,如果您透過相同的設定檔與兩個不同的人交談,他們也可以證明他們連線的是同一個人。", - "hero-overlay-card-1-p-6": "請參閱 SimpleX 白皮書以閱讀更多資訊。", + "hero-overlay-card-1-p-6": "請參閱 SimpleX 白皮書以閱讀更多資訊。", "hero-overlay-card-3-p-1": "Trail of Bits 是一家領先的安全和技術顧問公司,客戶包括大型科技公司、政府機構和主要的區塊鏈專案。", "simplex-network-overlay-card-1-p-1": "點對點通訊協定和應用程式有多種問題,使得它們不如 SimpleX 可靠、分析起來更複雜,而且易受幾種類型的攻擊。", "simplex-network-overlay-card-1-li-2": "SimpleX 的設計與大多數點對點網路不同,沒有任何類型的全局使用者標識符,即使是臨時標識符。SimpleX 只使用臨時的標識符對,提供更好的匿名性和元資料保護。", @@ -145,8 +145,8 @@ "simplex-unique-overlay-card-3-p-4": "傳送與接收的伺服器流量之間沒有共同的標識符或密文—如果有人觀察,即使 TLS 遭到破壞,也無法輕易確定誰與誰通訊。", "join-the-REDDIT-community": "加入Reddit社群", "privacy-matters-overlay-card-3-p-1": "人人都應關心自己通訊的隱私和安全;無害的對話可能會讓您陷入危險,即使您沒有什麼要隱瞞的。", - "privacy-matters-overlay-card-3-p-3": "普通人會因為他們在網路上分享的內容而被捕,即使是透過他們的「匿名」帳號,即使是在民主國家。", - "privacy-matters-overlay-card-2-p-1": "不久前我們觀察到大選被一家聲譽良好的顧問公司操縱,該公司利用我們的社交圖來扭曲我們對真實世界的看法,並操縱我們的選票。", + "privacy-matters-overlay-card-3-p-3": "普通人會因為他們在網路上分享的內容而被捕,即使是透過他們的「匿名」帳號,即使是在民主國家。", + "privacy-matters-overlay-card-2-p-1": "不久前我們觀察到大選被一家聲譽良好的顧問公司操縱,該公司利用我們的社交圖來扭曲我們對真實世界的看法,並操縱我們的選票。", "comparison-section-list-point-2": "基於DNS的位址", "comparison-section-list-point-1": "通常基於電話號碼,有時用戶名", "simplex-private-card-6-point-1": "許多通訊網路容易受到伺服器或網路供應商的中間人攻擊。", diff --git a/website/src/_includes/blog_previews/20221108.html b/website/src/_includes/blog_previews/20221108.html index f07180a17b..64174a0bcb 100644 --- a/website/src/_includes/blog_previews/20221108.html +++ b/website/src/_includes/blog_previews/20221108.html @@ -2,7 +2,7 @@

SimpleX Chat security has been assessed by - Trail of Bits, + Trail of Bits, 4 issues were identified, and 3 of them are fixed in v4.2.

diff --git a/website/src/_includes/blog_previews/20221206.html b/website/src/_includes/blog_previews/20221206.html index 55530cd301..df7e113250 100644 --- a/website/src/_includes/blog_previews/20221206.html +++ b/website/src/_includes/blog_previews/20221206.html @@ -1,9 +1,9 @@

SimpleX Chat November reviews:

-

Privacy Guides recommendations.

-

Review by Mike Kuketz.

-

The messenger matrix.

-

Supernova review and messenger ratings.

+

Privacy Guides recommendations.

+

Review by Mike Kuketz.

+

The messenger matrix.

+

Supernova review and messenger ratings.

v4.3 is released:

diff --git a/website/src/_includes/contact_page.html b/website/src/_includes/contact_page.html index 0616665a70..5fbd13c1f2 100644 --- a/website/src/_includes/contact_page.html +++ b/website/src/_includes/contact_page.html @@ -17,11 +17,11 @@

{{ "contact-hero-p-3" | i18n({}, lang ) | safe }}

- App Store - Google Play + App Store + Google Play - TestFlight - Android APK + TestFlight + Android APK
@@ -54,8 +54,8 @@

{{ "install-simplex-app" | i18n({}, lang ) | safe }}

- - + +
diff --git a/website/src/_includes/footer.html b/website/src/_includes/footer.html index 31c9d9e557..521e717eb5 100644 --- a/website/src/_includes/footer.html +++ b/website/src/_includes/footer.html @@ -8,9 +8,11 @@ `; @@ -674,7 +674,7 @@ if (nextMeta.description) html += `

${nextMeta.description}

`; html += '

'; html += `${appState.totalMinted} NFTs minted. `; - html += `Explorer ${linkSvg}`; + html += `Explorer ${linkSvg}`; html += '

'; if (withAttributes) { html += renderAttributes(nextMeta.attributes, appState.nextOpenedTraits); diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index ab56059931..0d4d9619e5 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -67,12 +67,12 @@ @@ -126,7 +126,7 @@ diff --git a/website/src/crowdfunding.html b/website/src/crowdfunding.html index cf12b7b31a..3b0b293f02 100644 --- a/website/src/crowdfunding.html +++ b/website/src/crowdfunding.html @@ -1149,7 +1149,7 @@ templateEngineOverride: njk
-

Get a stake
in SimpleX Chat

+

Own a part
of SimpleX Chat

We are building a network that people own.
We invite you to invest and become part of it.

diff --git a/website/src/directory.html b/website/src/directory.html index 08bb047aa5..679534d41a 100644 --- a/website/src/directory.html +++ b/website/src/directory.html @@ -267,7 +267,7 @@ active_directory: true

SimpleX Directory

Welcome to the selected users' communities that you can join via SimpleX Chat app.

-

SimpleX Directory is also available as a SimpleX chat bot.

+

SimpleX Directory is also available as a SimpleX chat bot.

Read about how to add your community.

diff --git a/website/src/fdroid.html b/website/src/fdroid.html index ff56b9cc95..67e4821e20 100644 --- a/website/src/fdroid.html +++ b/website/src/fdroid.html @@ -48,7 +48,7 @@ templateEngineOverride: njk

{{ "f-droid-org-repo" | i18n({}, lang ) | safe }}

- +

{{ "stable-versions-built-by-f-droid-org" | i18n({}, lang ) | safe }}

{{ "releases-to-this-repo-are-done-1-2-days-later" | i18n({}, lang ) | safe }}.

{{ "f-droid-page-f-droid-org-repo-section-text" | i18n({}, lang ) | safe }}

diff --git a/website/src/file.html b/website/src/file.html index 5eb545ef39..e5a25b446b 100644 --- a/website/src/file.html +++ b/website/src/file.html @@ -230,11 +230,11 @@ noEvents: true {{ "file-cta-subheading" | i18n({}, lang) | safe }}

- - + + - - + +
diff --git a/website/src/index.html b/website/src/index.html index 57580c1f4d..4b8491640c 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -166,7 +166,7 @@ active_home: true

{{ "index-hero-h1" | i18n({}, lang) | safe }}

{{ "index-hero-h2" | i18n({}, lang) | safe }}

{{ "index-hero-p1" | i18n({}, lang) | safe }}

-

{{ "index-hero-invest" | i18n({}, lang) | safe }} {{ "learn-more" | i18n({}, lang) }}.

+

{{ "index-hero-invest" | i18n({}, lang) | safe }} {{ "learn-more" | i18n({}, lang) }}.

- - + + - - + +
@@ -251,7 +251,7 @@ active_home: true

{{ "index-token-h2" | i18n({}, lang) }}

{{ "index-token-p1" | i18n({}, lang) }}

{{ "index-token-p2-cf" | i18n({}, lang) | safe }}

- {{ "index-token-cta-cf" | i18n({}, lang) | safe }} + {{ "index-token-cta-cf" | i18n({}, lang) | safe }} @@ -288,36 +288,36 @@ active_home: true
- - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + +

{{ "index-directory-h2" | i18n({}, lang) }}

diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc index 2f88e885b9..b0a1696010 100644 --- a/website/src/js/channel-preview.jsc +++ b/website/src/js/channel-preview.jsc @@ -1037,11 +1037,11 @@ function renderInfoContent(container, data, channelLink, subscriberCount, showAp } } -var BADGE_APPLE = 'App Store'; -var BADGE_GOOGLE = 'Google Play'; -var BADGE_FDROID = 'F-Droid'; -var BADGE_APK = 'APK Download'; -var BADGE_TESTFLIGHT = 'TestFlight'; +var BADGE_APPLE = 'App Store'; +var BADGE_GOOGLE = 'Google Play'; +var BADGE_FDROID = 'F-Droid'; +var BADGE_APK = 'APK Download'; +var BADGE_TESTFLIGHT = 'TestFlight'; function renderAppBadges(container) { const title = document.createElement('p'); diff --git a/website/src/js/directory.jsc b/website/src/js/directory.jsc index 7d99e71dad..5ca3b7fbcf 100644 --- a/website/src/js/directory.jsc +++ b/website/src/js/directory.jsc @@ -338,6 +338,7 @@ function displayEntries(entries) { imgLinkElement.href = groupLinkUri; } imgLinkElement.target = "_blank"; + imgLinkElement.rel = "noopener"; imgLinkElement.title = `Join ${displayName}`; const imgElement = document.createElement('img'); diff --git a/website/src/js/simplex-lib.jsc b/website/src/js/simplex-lib.jsc index ffec278ca2..d05dd6a931 100644 --- a/website/src/js/simplex-lib.jsc +++ b/website/src/js/simplex-lib.jsc @@ -51,7 +51,7 @@ function isCurrentSite(uri) { } function targetBlank(uri) { - return isCurrentSite(uri) ? '' : ' target="_blank"' + return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"' } function renderMarkdown(fts) { @@ -98,7 +98,7 @@ function renderMarkdown(fts) { case 'simplexLink': { const { showText, linkType, simplexUri, smpHosts } = format; const linkText = showText ? escapeHtml(showText) : getSimplexLinkDescr(linkType); - html += `${linkText} (${escapeHtml(viaHost(smpHosts))})`; + html += `${linkText} (${escapeHtml(viaHost(smpHosts))})`; break; } case 'command': diff --git a/website/src/livestream.html b/website/src/livestream.html index a0a6a2762e..33f6575101 100644 --- a/website/src/livestream.html +++ b/website/src/livestream.html @@ -79,7 +79,7 @@ templateEngineOverride: njk
diff --git a/website/src/messaging.html b/website/src/messaging.html index 994aa37b1b..33b01cfd9e 100644 --- a/website/src/messaging.html +++ b/website/src/messaging.html @@ -318,7 +318,7 @@ templateEngineOverride: njk
  • {{ "messengers-comparison-section-list-point-1" | i18n({}, lang ) | safe }}
  • {{ "messengers-comparison-section-list-point-2" | i18n({}, lang ) | safe }}
  • {{ "messengers-comparison-section-list-point-3" | i18n({}, lang ) | safe }}
  • -
  • {{ "messengers-comparison-section-list-point-4" | i18n({}, lang ) | safe }} — {{ "see-here" | i18n({}, lang ) | safe }}.
  • +
  • {{ "messengers-comparison-section-list-point-4" | i18n({}, lang ) | safe }} — {{ "see-here" | i18n({}, lang ) | safe }}.
  • {{ "messengers-comparison-section-list-point-5" | i18n({}, lang ) | safe }}
  • {{ "messengers-comparison-section-list-point-6" | i18n({}, lang ) | safe }}
  • From 000258224c3410a37fa03797a9a0dcb77d0980cd Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:14:55 +0000 Subject: [PATCH 25/31] desktop: fix crash in SimpleX address screen when there is no profile (#7679) * desktop: fix crash in SimpleX address screen when there is no profile * desktop: close SimpleX address screen when there is no profile * desktop: rerun address screen self-close when its modal is reused or the stack changes * desktop: close SimpleX address screen only when it is the top modal * plans: correct address no-profile crash triggers and fix description * plans: correct address no-profile crash fix description and verification * plans: tighten address no-profile crash wording * plans: add self-destruct trigger to address no-profile crash * plans: qualify self-destruct trigger on desktop * plans: drop desktop-only title from address no-profile crash * plans: no connected mobile, not no mobile * spec, product: document isLastModal and address screen without profile * plans, spec, product: plan sections, address row spec, GAP-08 and GAP-09 * spec, product, plans: fix GAP-08/09 wording, table placement, ModalManager line * spec, product: GAP-08/09 accuracy, gap count, clickable ModalManager location * product, plans: GAP-09 at the level the code supports; qualify picker auto-open * spec, product, plans: fix GAP-09 locations, stale line links, wording * spec, product, plans: update plain-text line numbers; GAP-09 read wording * plans: name the picker auto-open condition * spec, plans: wording; security note * plans: self-destruct did not crash on Android master; not a trigger * plans: self-destruct path possible but unconfirmed; Android recovers silently * spec, product, plans: drop GAP-09 and unrelated line refreshes * plan: Android self-destruct path confirmed in logcat * desktop: close Chat preferences screen when there is no profile, as the address screen does --- .../common/views/chatlist/UserPicker.kt | 22 ++++- .../simplex/common/views/helpers/ModalView.kt | 2 + .../product/views/user-profiles.md | 4 +- apps/multiplatform/spec/client/chat-list.md | 2 + apps/multiplatform/spec/client/navigation.md | 1 + ...-10-10-desktop-address-no-profile-crash.md | 99 +++++++++++++++++++ 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 plans/2026-10-10-desktop-address-no-profile-crash.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 652d072c9c..62fd634d29 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -220,14 +220,32 @@ fun UserPicker( UserPickerOptionRow( painterResource(MR.images.ic_qr_code), if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address), - showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped + showCustomModal { it, close -> + val user = it.currentUser.value + if (user != null) { + UserAddressView(it, shareViaProfile = user.addressShared, close = close) + } else { + val modal = this + LaunchedEffect(modal, ModalManager.start.modalCount.value) { + if (ModalManager.start.isLastModal(modal)) close() + } + } + }, disabled = stopped ) UserPickerOptionRow( painterResource(MR.images.ic_toggle_on), stringResource(MR.strings.chat_preferences), click = if (stopped) null else ({ showCustomModal { m, close -> - PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close) + val user = m.currentUser.value + if (user != null) { + PreferencesView(m, user, close) + } else { + val modal = this + LaunchedEffect(modal, ModalManager.start.modalCount.value) { + if (ModalManager.start.isLastModal(modal)) close() + } + } }() }), disabled = stopped diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index b13c321f70..f8e81c8ee2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -115,6 +115,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + fun isLastModal(data: ModalData): Boolean = modalViews.getOrNull(modalViews.size - toRemove.size - 1)?.data === data + fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { showCustomModal(id = id, forceAnimated = forceAnimated) { close -> ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content() }) diff --git a/apps/multiplatform/product/views/user-profiles.md b/apps/multiplatform/product/views/user-profiles.md index dfc37a5e8d..9f31c8285e 100644 --- a/apps/multiplatform/product/views/user-profiles.md +++ b/apps/multiplatform/product/views/user-profiles.md @@ -28,8 +28,8 @@ Overlay panel triggered from `ChatListView` toolbar: | Device picker row | `DevicePickerRow` showing local device and connected remote hosts (Desktop only); pill-shaped buttons with connect/disconnect actions | | Active user profile | `ProfilePreview` of current user (Desktop: single row; Android: full user list) | | User list | `UserPickerUsersSection` with all visible non-hidden profiles; tap to switch, long-press disabled | -| SimpleX address | Row to open `UserAddressView` (create or view address) | -| Chat preferences | Row to open `PreferencesView` | +| SimpleX address | Row to open `UserAddressView` (create or view address); with no active profile the screen closes itself (if another screen covers it, when the user goes back to it) | +| Chat preferences | Row to open `PreferencesView`; with no active profile the screen closes itself (if another screen covers it, when the user goes back to it) | | Chat profiles | Row to open `UserProfilesView` (or `CreateProfile` when no users exist on Desktop) | | Use from desktop/mobile | Android: "Use from desktop" (`ConnectDesktopView`); Desktop: "Link a mobile" / "Linked mobiles" (`ConnectMobileView`) | | Settings | Row to open `SettingsView` with `ColorModeSwitcher` trailing | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index e34da9613d..aba6d3ad5f 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -279,6 +279,8 @@ fun UserPicker( - **Android:** Renders as a slide-up overlay panel on the chat list, triggered by tapping the user profile button in the toolbar. - **Desktop:** Rendered inline in the left column of `DesktopScreen`, always accessible. - Closes automatically when any `ModalManager.start` modal opens. +- The SimpleX address row opens `UserAddressView` in `ModalManager.start`. With no active user (`currentUser == null`) the modal draws nothing and closes itself when it is the top modal (`ModalManager.start.isLastModal`); if another screen covers it, it closes when the user goes back to it. +- The Chat preferences row closes its modal the same way when there is no active user. ### Content diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index d0340e8d23..7838abae69 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -256,6 +256,7 @@ Each `ModalManager` maintains a stack of `ModalViewHolder` objects with: | `closeModals` | Pop all modals from the stack | | `closeModalsExceptFirst` | Pop all but the bottom modal | | `hasModalsOpen()` | Check if any modals are on the stack | +| `isLastModal(data)` | Check if the modal with this `ModalData` is the top modal not being removed | | `showInView` | Render the current modal stack into the composable tree | ### Usage Pattern diff --git a/plans/2026-10-10-desktop-address-no-profile-crash.md b/plans/2026-10-10-desktop-address-no-profile-crash.md new file mode 100644 index 0000000000..c51eeaff04 --- /dev/null +++ b/plans/2026-10-10-desktop-address-no-profile-crash.md @@ -0,0 +1,99 @@ +# Crash in the SimpleX address screen when there is no profile + +## Problem + +A Windows user who uses the desktop app only through a linked mobile reported this crash: + +``` +java.lang.NullPointerException + at chat.simplex.common.views.chatlist.ComposableSingletons$UserPickerKt$lambda$-1608374373$1.invoke(UserPicker.kt:223) + at chat.simplex.common.views.chatlist.UserPickerKt$UserPicker$6$showCustomModal$1$1$1.invoke(UserPicker.kt:157) + at chat.simplex.common.views.helpers.ModalManager$showInView$4$1$1.invoke(ModalView.kt:218) +``` + +The "SimpleX address" screen crashes whenever it is shown while there is no active user. + +## Solution summary + +When there is no current user, the address modal closes itself instead of composing `UserAddressView`. It closes only while it is the top modal, so it does not close a different screen. The new `ModalManager.isLastModal` checks that. + +The "Chat preferences" row, which in paths 1 and 2 below left an invisible modal instead of crashing, closes itself the same way. + +## Technical design + +### Cause + +The "SimpleX address" row of the user picker opens `UserAddressView` with `shareViaProfile = it.currentUser.value!!.addressShared`. The modal content reads `currentUser`, so it recomposes whenever `currentUser` changes. It throws whenever it is composed while `currentUser` is null, that is, when there is no active user. A desktop that was only used with a linked mobile has no active user of its own. + +The screen can be shown in that state in three ways: + +1. **The mobile disconnects.** The screen was opened for the mobile's profile, and then the mobile disconnects. `switchUIRemoteHost(null)` sets `currentUser` to the local user, which is null, and the left-panel modals stay open. + - If the address screen is on top, it crashes when it recomposes. + - If another screen is on top of it, it crashes when the user goes back to it. + + `UserAddressView` closes itself when the user changes (`KeyChangeEffect` on the user), but with a null user it is never reached. +2. **The row is clicked with no active user.** With no active profile, the user picker still shows the "Create SimpleX address" row. With no profile and no mobile in use, the picker even opens by itself, at startup or when the state begins while no left-panel screen is open (`App.kt`, `desktopNoUserNoRemote`). Clicking the row crashes immediately. + + At startup, no profile and no connected mobile needs a mobile that was linked before; with no linked mobile, startup shows onboarding instead (`Core.kt`). + + No active user also follows from deleting the active profile when no other visible profile remains, including the only profile, and including the last visible profile of a connected mobile. `doRemoveUser` (`UserProfilesView.kt`) then calls `changeActiveUser_` with no user, which sets `currentUser` from `apiGetActiveUser`. After the deletion there is no active user, so it is null. + +3. **The self-destruct passcode is entered while the screen is open.** `deleteStorageAndRestart` (`LocalAuthView.kt`) calls `reinitChatController`. That calls `initChatController`, which sets `currentUser` from the new empty database to null (`Core.kt`). Only later does `deleteStorageAndRestart` create the new profile and call `closeAllModalsEverywhere`. + - On Android, the shared modal stack stays composed under the lock screen, so the address screen can recompose with a null user and throw. It throws only if a frame falls after `currentUser` becomes null and before onboarding replaces the main screen, so it does not happen on every run. Android's main loop catches exceptions (`SimplexApp.kt`). `GlobalExceptionsHandler` (`UI.android.kt`) then closes the top modal, recreates the activity and posts a crash alert, and the self-destruct flow hides all alerts at its end, so no crash is visible. + - On desktop, `initChatController` replaces the main screen with the splash screen shortly afterwards (`localUserCreated = null`), so the crash needs a frame to fall in that short window. This was not tested. + +The "Chat preferences" row has the same cause without the crash. It opens `PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)`, so with no active user the modal content returns early. `PreferencesView` closes itself when the user changes (`KeyChangeEffect` on the user, `Preferences.kt`), but with a null user it is never reached either. The modal stays on the left-panel stack with nothing drawn and no back button, in paths 1 and 2 above. + +### Self-closing modal + +Both rows use the same effect. `ModalManager.closeModal` closes the top modal that is not already being removed, whichever modal calls it. So the effect closes only when `ModalManager.isLastModal(data)` reports that this modal is that one. + +The effect is keyed on the modal and on the modal count of `ModalManager.start` (the left panel on desktop, the shared stack on Android). So it checks again when the count changes, or when its composition is reused for another modal, while this content is still composed. Other than the top modal, a modal stays composed only while it animates out (250 ms, longer if the animation is interrupted). For example, a quick second click on the row reuses the composition of the first modal, which is still animating out. + +A modal that is covered after an animation ends is disposed. It is composed afresh when it becomes the top again, for example when the user goes back to it, and the effect runs then. + +With the `isLastModal` check, the effect does not close a different screen, such as one opened just before or just after the address screen during an animation. This holds for stack changes on the main thread. As before, `ModalManager` is not synchronized with background calls such as `closeAllModalsEverywhere`. + +### Visible behaviour + +When there is no active user: +- Clicking either row now only hides the picker, as opening any modal does. The modal closes itself, and the picker is not reopened. +- After the mobile disconnects with the address or preferences screen open, the picker does not reopen by itself (observed in testing). When the state with no user and no mobile in use begins, the app checks for open left-panel modals, and the modal closes itself only after that check. Master does the same when any other left-panel screen is open. + +The change is in common code. On Android the user picker is not reachable without an active user, because deleting the last visible profile returns to onboarding. In the self-destruct case on Android, the screen can no longer throw, so `GlobalExceptionsHandler` does not have to recover. With an active user, behaviour does not change on any platform. + +### Security + +The change is UI-only and crosses no trust boundary: it reads local state and closes a local screen. It sends nothing and changes no stored data. + +### Rejected alternative + +An early `return@showCustomModal`, as the "Chat preferences" row did, would avoid the crash. But it would leave an invisible modal on the left-panel stack, with no back button. It stays until something closes the left-panel modals, such as a click on the centre panel. If a profile is created first, it comes back as the address screen. The "Chat preferences" row has this behaviour on master; this change gives it the self-closing effect too. + +## Implementation steps + +1. `views/helpers/ModalView.kt`: add `ModalManager.isLastModal(data)`, true when `data` belongs to the top modal that is not being removed. +2. `views/chatlist/UserPicker.kt`: in the "SimpleX address" row, compose `UserAddressView` only when `currentUser` is not null. Otherwise run `LaunchedEffect(modal, ModalManager.start.modalCount.value)`, which closes the modal when `isLastModal(modal)` is true. Do the same in the "Chat preferences" row for `PreferencesView`. +3. Documentation, under `apps/multiplatform/`: + - `spec/client/navigation.md`: list `isLastModal` among the `ModalManager` methods. + - `spec/client/chat-list.md`, UserPicker section: describe the address and preferences rows closing themselves with no active user. + - `product/views/user-profiles.md`: describe both rows with no active profile. + +## Verification + +Desktop AppImages from master and from this branch, each with a fresh database, unless stated otherwise. The v7.1.0-beta.6 CLI (core v7.1.0.10) acts as the linked mobile (`/connect remote ctrl`). "Leftover" is checked by creating a profile afterwards: a leftover modal reappears as the address screen when onboarding finishes. + +- master (at the merge base), case 1: NPE at `UserPicker.kt:223`. +- master (at the merge base), case 2: NPE at `UserPicker.kt:223` with the reported stack (`showCustomModal` 157, `ModalView.kt:218`). +- This branch, case 1 with the address screen on top: no crash, and the screen closes. +- This branch, case 1 with the "Address or 1-time link?" screen (opened from "SimpleX address or 1-time link?") on top: no crash. That screen stays open after the disconnect, as on master. Going back closes the address screen and shows the chat list. +- This branch, case 2, double click (seven runs, 8 to 40 ms apart): no crash, and no leftover. With `LaunchedEffect(Unit)` the address screen reappeared after onboarding in 2 of 3 runs at 30 ms. +- This branch, case 2, "Settings" then "Create SimpleX address" 100 ms apart: no crash, and Settings stays open. With the close keyed on the modal count but without the `isLastModal` check, Settings was closed too. +- This branch, with a local profile: the address screen opens and closes normally. When the mobile disconnects with its address screen open, the screen closes through `UserAddressView`'s own user-change effect, and the app returns to the local profile. +- "Chat preferences", desktop with no profile, the same CLI linked as the mobile: + - Preferences open, then the mobile disconnects. Before this change the screen disappeared, and after a profile was created it came back as "Your preferences" over the chat. With this change it does not come back, and there is no crash. + - Row clicked with no profile and no mobile: nothing opens, and nothing comes back after a profile is created. + - With a profile: the screen opens and closes normally. +- Android (Waydroid: Android 13 with arm64 translation), debug APKs of master and of this branch, fresh app data, case 3 with the address screen opened from the user picker: + - master, two runs: in one, logcat shows `App crashed, thread name: main` with the NPE at `UserPicker.kt:223`; in the other, no exception. No crash is visible in either, as in an earlier manual test. + - This branch: one run completed to the new profile with no exception in logcat. As master did not throw on every run, this checks for no new failure rather than proving the fix. An earlier run hung during self-destruct, with the main thread, a coroutine thread and a Haskell worker all waiting for the native allocator lock under arm64 translation. From 23ca3ea42fa03a064243379a8b969f19b16e0893 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:37:37 +0000 Subject: [PATCH 26/31] desktop: hide SimpleX address and chat preferences rows with no profile and no mobile in use (#7717) --- .../common/views/chatlist/UserPicker.kt | 56 ++++++++++--------- .../product/views/user-profiles.md | 4 +- apps/multiplatform/spec/client/chat-list.md | 1 + ...sktop-hide-profile-rows-without-profile.md | 49 ++++++++++++++++ 4 files changed, 81 insertions(+), 29 deletions(-) create mode 100644 plans/2026-10-14-desktop-hide-profile-rows-without-profile.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 62fd634d29..ea567d7da6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -217,41 +217,43 @@ fun UserPicker( @Composable fun SecondSection() { - UserPickerOptionRow( - painterResource(MR.images.ic_qr_code), - if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address), - showCustomModal { it, close -> - val user = it.currentUser.value - if (user != null) { - UserAddressView(it, shareViaProfile = user.addressShared, close = close) - } else { - val modal = this - LaunchedEffect(modal, ModalManager.start.modalCount.value) { - if (ModalManager.start.isLastModal(modal)) close() - } - } - }, disabled = stopped - ) - UserPickerOptionRow( - painterResource(MR.images.ic_toggle_on), - stringResource(MR.strings.chat_preferences), - click = if (stopped) null else ({ - showCustomModal { m, close -> - val user = m.currentUser.value + if (!chatModel.desktopNoUserNoRemote) { + UserPickerOptionRow( + painterResource(MR.images.ic_qr_code), + if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address), + showCustomModal { it, close -> + val user = it.currentUser.value if (user != null) { - PreferencesView(m, user, close) + UserAddressView(it, shareViaProfile = user.addressShared, close = close) } else { val modal = this LaunchedEffect(modal, ModalManager.start.modalCount.value) { if (ModalManager.start.isLastModal(modal)) close() } } - }() - }), - disabled = stopped - ) + }, disabled = stopped + ) + UserPickerOptionRow( + painterResource(MR.images.ic_toggle_on), + stringResource(MR.strings.chat_preferences), + click = if (stopped) null else ({ + showCustomModal { m, close -> + val user = m.currentUser.value + if (user != null) { + PreferencesView(m, user, close) + } else { + val modal = this + LaunchedEffect(modal, ModalManager.start.modalCount.value) { + if (ModalManager.start.isLastModal(modal)) close() + } + } + }() + }), + disabled = stopped + ) + } if (appPlatform.isDesktop) { - Divider(Modifier.padding(DEFAULT_PADDING)) + if (!chatModel.desktopNoUserNoRemote) Divider(Modifier.padding(DEFAULT_PADDING)) val inactiveUsers = users.filter { !it.user.activeUser } diff --git a/apps/multiplatform/product/views/user-profiles.md b/apps/multiplatform/product/views/user-profiles.md index 9f31c8285e..38a0e13874 100644 --- a/apps/multiplatform/product/views/user-profiles.md +++ b/apps/multiplatform/product/views/user-profiles.md @@ -28,8 +28,8 @@ Overlay panel triggered from `ChatListView` toolbar: | Device picker row | `DevicePickerRow` showing local device and connected remote hosts (Desktop only); pill-shaped buttons with connect/disconnect actions | | Active user profile | `ProfilePreview` of current user (Desktop: single row; Android: full user list) | | User list | `UserPickerUsersSection` with all visible non-hidden profiles; tap to switch, long-press disabled | -| SimpleX address | Row to open `UserAddressView` (create or view address); with no active profile the screen closes itself (if another screen covers it, when the user goes back to it) | -| Chat preferences | Row to open `PreferencesView`; with no active profile the screen closes itself (if another screen covers it, when the user goes back to it) | +| SimpleX address | Row to open `UserAddressView` (create or view address); hidden on Desktop when there is no active profile and no mobile is in use; a screen shown with no active profile closes itself (if another screen covers it, when the user goes back to it) | +| Chat preferences | Row to open `PreferencesView`; hidden on Desktop when there is no active profile and no mobile is in use; a screen shown with no active profile closes itself (if another screen covers it, when the user goes back to it) | | Chat profiles | Row to open `UserProfilesView` (or `CreateProfile` when no users exist on Desktop) | | Use from desktop/mobile | Android: "Use from desktop" (`ConnectDesktopView`); Desktop: "Link a mobile" / "Linked mobiles" (`ConnectMobileView`) | | Settings | Row to open `SettingsView` with `ColorModeSwitcher` trailing | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index aba6d3ad5f..5a63ad92bc 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -279,6 +279,7 @@ fun UserPicker( - **Android:** Renders as a slide-up overlay panel on the chat list, triggered by tapping the user profile button in the toolbar. - **Desktop:** Rendered inline in the left column of `DesktopScreen`, always accessible. - Closes automatically when any `ModalManager.start` modal opens. +- On Desktop, when there is no active user and no mobile is in use (`desktopNoUserNoRemote`), the SimpleX address and Chat preferences rows and the divider below them are hidden. - The SimpleX address row opens `UserAddressView` in `ModalManager.start`. With no active user (`currentUser == null`) the modal draws nothing and closes itself when it is the top modal (`ModalManager.start.isLastModal`); if another screen covers it, it closes when the user goes back to it. - The Chat preferences row closes its modal the same way when there is no active user. diff --git a/plans/2026-10-14-desktop-hide-profile-rows-without-profile.md b/plans/2026-10-14-desktop-hide-profile-rows-without-profile.md new file mode 100644 index 0000000000..e906a6f275 --- /dev/null +++ b/plans/2026-10-14-desktop-hide-profile-rows-without-profile.md @@ -0,0 +1,49 @@ +# Hide the address and preferences rows when there is no profile and no mobile in use + +## Problem + +On a desktop with no active profile and no mobile in use, the user picker shows "Create SimpleX address" as its first option row, then "Chat preferences" and, below a divider, "Create chat profile". The picker opens by itself in this state when no left-panel screen is open (`App.kt`). Neither of the first two rows can work without a profile: + +- "Create SimpleX address" crashed with a NullPointerException at `UserPicker.kt:223` (`:216` in v6.1, `:222` in v6.2–v6.4) before #7679. GitHub reports from using the row: #5068, #5476, #6194, #7067 (Linux; comments on #5068 and #6194 report the same on Windows 11). +- "Chat preferences" left an invisible modal with no back button. +- With #7679 both screens close themselves instead, so a click only hides the picker and nothing opens. + +Users have told the team that the crash stops them from creating a profile. On v7.0.4 a profile can still be created from "Create chat profile" after the crash, but the first row the user tries is one that cannot work. + +The same crash also happens when a mobile disconnects while its address screen is open (#6452, macOS: the same stack, reported alongside phone disconnects). That path is fixed by #7679, not by this change. + +## Solution summary + +Hide the "SimpleX address" and "Chat preferences" rows when the desktop has no active user and no mobile in use (`chatModel.desktopNoUserNoRemote`). The divider below them is hidden with them. This is the state in which the picker already shows "Create chat profile" instead of "Your chat profiles". While a connected mobile is in use, both rows stay and act on the mobile's profile; if the mobile has no active profile, the screens close themselves (#7679). + +Before the new user picker (#4796), `SettingsView` showed both rows only inside `if (profile != null)`, with "Create chat profile" in the `else if (chatModel.localUserCreated.value == false)` branch. + +## Technical design + +`SecondSection` in `UserPicker.kt` emits both rows inside `if (!chatModel.desktopNoUserNoRemote)`. The desktop divider below them belongs to the same group and is drawn under the same condition. The composable getter reads `currentUser` and `currentRemoteHost` state, so the picker updates as soon as a profile is created, a mobile is connected, selected or disconnected, or the desktop is switched to "This device" with no profile. On Android `desktopNoUserNoRemote` is always false, so nothing changes there. + +The self-closing modals from #7679 stay. Hiding the rows only removes the entry point in that state. A screen shown with no active user in any other state still relies on #7679's handling; #7679's plan lists the paths. For example: +- a screen opened while there was a user, which then loses it: a mobile disconnects, or the self-destruct passcode is entered, with the address or preferences screen open; +- a mobile in use with no active profile (its last visible profile deleted): the rows are shown, and the screens close themselves. + +### Security + +UI-only: it hides two rows and the divider below them, based on local state. No data or trust boundary is involved. + +## Implementation steps + +1. `views/chatlist/UserPicker.kt`: in `SecondSection`, wrap the "SimpleX address" and "Chat preferences" rows in `if (!chatModel.desktopNoUserNoRemote)`, and draw the desktop divider below them under the same condition. +2. Documentation, under `apps/multiplatform/`: + - `spec/client/chat-list.md`, UserPicker section: when the two rows are hidden. + - `product/views/user-profiles.md`: when both rows are hidden. + +## Verification + +Desktop AppImage of this branch, fresh database, the v7.1.0-beta.6 CLI as the linked mobile; Android debug APK of this branch on Waydroid. + +- Mobile connected, no desktop profile: both rows are shown for the mobile's profile. "Create SimpleX address" creates the address on the mobile (`/show_address` on the CLI returns it). +- Mobile connected, switched to "This device" (no profile): both rows are hidden and "Create chat profile" is shown. Switching back to the mobile shows them again. +- Address screen open, then the mobile disconnects: the screen closes (#7679), no crash, and the picker then shows no address or preferences rows. +- "Create chat profile" from that state: the profile is created, both rows appear and open their screens. +- No profile, mobile linked but not in use: the device row, then "Create chat profile", with no divider. After deleting the linked mobile ("Linked mobiles", right-click, "Delete"): "Create chat profile" is the first row. The spacing matches the gap between rows. After creating a profile, both rows and the divider are back. +- Android, with a profile: both rows are shown and open their screens; no crash in logcat. From f5b13f608f3d56f59d772d3c3e1f264fbe2be280 Mon Sep 17 00:00:00 2001 From: Simplex Operations Date: Thu, 15 Oct 2026 06:18:30 +0000 Subject: [PATCH 27/31] packages: release nodejs 7.1.0-beta.6.3 (#7720) --- packages/simplex-chat-nodejs/package.json | 2 +- packages/simplex-chat-nodejs/src/release.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index ca62e57fa6..67eefdee4a 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.6.2", + "version": "7.1.0-beta.6.3", "main": "dist/index.js", "types": "dist/index.d.ts", "gypfile": false, diff --git a/packages/simplex-chat-nodejs/src/release.ts b/packages/simplex-chat-nodejs/src/release.ts index ccd436e250..b56435e210 100644 --- a/packages/simplex-chat-nodejs/src/release.ts +++ b/packages/simplex-chat-nodejs/src/release.ts @@ -2,7 +2,7 @@ export const LIBS_RELEASE = "core-7.1.0.11" -export const ADDON_RELEASE: string | undefined = undefined +export const ADDON_RELEASE: string | undefined = "nodejs-addon-308c0960c707" export const SHA256: {readonly [asset: string]: string} = { "simplex-chat-libs-linux-aarch64.zip": "d66aa1b30b9fa20225dad8ef75a9c605961e1c0adde2ba0283e2fcac4e15d260", @@ -11,4 +11,9 @@ export const SHA256: {readonly [asset: string]: string} = { "simplex-chat-libs-macos-aarch64.zip": "aa74608b149fff7508a47eaac4284dcc6d189c6ed8b7c18428df9158bc176a61", "simplex-chat-libs-macos-x86_64.zip": "a0e21457e1077be7f71b46c1c27452945b2cee2b6c50efb0004412ce5b7042ec", "simplex-chat-libs-windows-x86_64.zip": "c01dad574daa101a1cbfd31ddbf2b3d0d986a4a42f97d789d2f878b2050b2b4b", + "simplex-chat-nodejs-linux-aarch64.node": "3636a82c56c48229e0326fa03d6ceb5c16dd3afbcc1e86337783dc53a7328d67", + "simplex-chat-nodejs-linux-x86_64.node": "55f3479b03fdb57591179ed730da744f1aa362e7529df55221b60028415746e9", + "simplex-chat-nodejs-macos-aarch64.node": "a3472872fb0816f435f52ee5471088e6545ebffb95e89fbf8e1ab82275f2d56e", + "simplex-chat-nodejs-macos-x86_64.node": "290901f7f227019a8cee3acc832541c930c4926fc08110df25169810a9002f5a", + "simplex-chat-nodejs-windows-x86_64.node": "ec689373b4f0fef73253a8328f60b8e902a45ea67e756d42a1f759b44dfd7339", } From e8c98e82316cc9444fd24ebfa1bd388d9d8877f1 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 15 Oct 2026 08:41:57 +0100 Subject: [PATCH 28/31] core, ios: fix notifications for forwarded and channel messages (#7706) * plan: add notification flag for forwarded messages * core, ui: fix notifications for forwarded and relay messages * improvements * only send duplicate message event in NSE * query plans --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/SimpleX NSE/NSEAPITypes.swift | 3 + .../ios/SimpleX NSE/NotificationService.swift | 20 +- apps/ios/SimpleXChat/ChatTypes.swift | 11 +- bots/src/API/Docs/Events.hs | 1 + .../2026-10-13-forwarded-notification-flag.md | 330 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Delivery.hs | 1 + src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Library/Internal.hs | 43 ++- src/Simplex/Chat/Library/Subscriber.hs | 17 +- src/Simplex/Chat/Protocol.hs | 5 + src/Simplex/Chat/Store/Delivery.hs | 18 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20261012_delivery_job_notification.hs | 19 + src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20261012_delivery_job_notification.hs | 18 + .../SQLite/Migrations/agent_query_plans.txt | 13 +- .../SQLite/Migrations/chat_query_plans.txt | 10 +- .../Store/SQLite/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/View.hs | 1 + tests/ChatTests/Groups.hs | 208 ++++++++++- 22 files changed, 674 insertions(+), 60 deletions(-) create mode 100644 plans/2026-10-13-forwarded-notification-flag.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift index 35a838fff9..28073ef342 100644 --- a/apps/ios/SimpleX NSE/NSEAPITypes.swift +++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift @@ -89,6 +89,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) case newChatItems(user: UserRef, chatItems: [AChatItem]) + case duplicateGroupMessage(user: UserRef, member: GroupMember) case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) @@ -101,6 +102,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case .contactConnected: "contactConnected" case .receivedContactRequest: "receivedContactRequest" case .newChatItems: "newChatItems" + case .duplicateGroupMessage: "duplicateGroupMessage" case .rcvFileSndCancelled: "rcvFileSndCancelled" case .sndFileComplete: "sndFileComplete" case .sndFileRcvCancelled: "sndFileRcvCancelled" @@ -117,6 +119,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) + case let .duplicateGroupMessage(u, member): return withUser(u, String(describing: member)) case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 25df063f82..38e1b604be 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -73,6 +73,16 @@ public enum NSENotificationData { default: nil } } + + func replaces(_ bestAttempt: NSENotificationData) -> Bool { + switch (self, bestAttempt) { + case (.noNtf, .connectionEvent): true + case (.noNtf, _): false + case (.callInvitation, _): true + case (_, .callInvitation): false + default: true + } + } } // Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid @@ -573,9 +583,9 @@ class NotificationService: UNNotificationServiceExtension { // A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above. // If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once, // instead of requesting further messages. - if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil { + if let bestAttempt = notificationEntities[id]?.msgBestAttemptNtf, ntf.replaces(bestAttempt) { notificationEntities[id]?.msgBestAttemptNtf = ntf - } // otherwise keep call as best attempt + } return false } else { // We should not get to this branch, as notifications are not delivered for muted users. @@ -1022,8 +1032,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? cItem = autoReceiveFile(file) ?? cItem } let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf - let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir { - groupMember.id + let chatIdOrMemberId = if case let .group(groupInfo, _) = cInfo, let memberId = cItem.meta.forwardedByMember ?? cItem.chatDir.groupMemberId { + groupMemberEntityId(groupInfo.groupId, memberId) } else { chatItem.chatInfo.id } @@ -1031,6 +1041,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? } else { return nil } + case let .duplicateGroupMessage(_, member): + return (member.id, .noNtf) case let .rcvFileSndCancelled(_, aChatItem, _): cleanupFile(aChatItem) return nil diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a8c5de3672..186d7dab64 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3255,7 +3255,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var relayLink: String? public var memberVerifiedCode: SecurityCode? - public var id: String { "#\(groupId) @\(groupMemberId)" } + public var id: String { groupMemberEntityId(groupId, groupMemberId) } public var ready: Bool { get { activeConn?.connStatus == .ready } } public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } } public var sendMsgEnabled: Bool { get { @@ -3444,6 +3444,10 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ) } +public func groupMemberEntityId(_ groupId: Int64, _ groupMemberId: Int64) -> String { + "#\(groupId) @\(groupMemberId)" +} + public struct GroupSupportChat: Codable, Hashable { public var chatTs: Date public var unread: Int @@ -4217,6 +4221,10 @@ public enum CIDirection: Decodable, Hashable { } } + public var groupMemberId: Int64? { + if case let .groupRcv(groupMember) = self { groupMember.groupMemberId } else { nil } + } + public func sameDirection(_ dir: CIDirection) -> Bool { switch (self, dir) { case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId @@ -4242,6 +4250,7 @@ public struct CIMeta: Decodable, Hashable { public var userMention: Bool public var deletable: Bool public var editable: Bool + public var forwardedByMember: Int64? public var showGroupAsSender: Bool public var msgVerified: MsgVerified? diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index 48f307f6e4..e310fd54ef 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -203,6 +203,7 @@ undocumentedEvents = "CEvtContactRequestRejected", "CEvtContactSwitch", "CEvtCustomChatEvent", + "CEvtDuplicateGroupMessage", "CEvtGroupMemberRatchetSync", "CEvtGroupMemberSwitch", "CEvtServiceSubStatus", diff --git a/plans/2026-10-13-forwarded-notification-flag.md b/plans/2026-10-13-forwarded-notification-flag.md new file mode 100644 index 0000000000..a1962a6e0f --- /dev/null +++ b/plans/2026-10-13-forwarded-notification-flag.md @@ -0,0 +1,330 @@ +# Notification flag of forwarded messages + +Date: 2026-10-13. + +A message forwarded by a chat relay or by a forwarding group host is sent with the SMP notification flag when its original event has the flag by the rule of `hasNotification`. + +References are to `/code/simplex-chat-4`, branch `master`, commit `2f4a443dd`. Haskell paths without a directory are in `src/Simplex/Chat/`. `SMQ/` is `dist-newstyle/src/simplexmq-f058c38b0eecb6c0/src/Simplex/Messaging/`. `NSE` is `apps/ios/SimpleX NSE/NotificationService.swift`. + +## 1. Present behaviour +Path of a forwarded message: +``` +received Library/Subscriber.hs:1014 MSG of a member connection; the flag of the received SMP message is ignored + Library/Subscriber.hs:1069-1144 processEvent: a NewMessageDeliveryTask is returned for each event to forward +task Library/Subscriber.hs:1022 created when the user is the relay of a channel, or an admin or owner of a group (:3800), and the author is not blocked by an admin + Library/Subscriber.hs:1158-1176 createDeliveryTasks; createMsgDeliveryTask (Store/Delivery.hs:78) is called at :1165 and :1172 only + Store/Delivery.hs:137-170 the task is read with its message: msg_body is decoded to verifiedMsg, the original event +job Library/Subscriber.hs:4233-4240 DJDeliveryJob: accepted tasks of one scope (Store/Delivery.hs:178) are batched into one job (Messages/Batch.hs:84) + Library/Subscriber.hs:4246-4251 DJRelayRemoved: one job for one task + Library/Commands.hs:3256-3265 leaveChannelRelay: one job with the x.grp.leave of the relay + Store/Delivery.hs:285-328 the job is read as scope, sender ids, body and cursor; its events are stored only as the encoded body +sent Library/Subscriber.hs:4379-4423 channel: for each recipient, overflow profile batches, then the body; MsgFlags False (:4416) + Library/Subscriber.hs:4424-4487 channel support scope and regular group: deliver; MsgFlags False (:4483) +``` +Types (`Delivery.hs`): +``` +DeliveryTaskContext :48 + jobScope :: DeliveryJobScope + sentAsGroup :: ShowGroupAsSender +DeliveryJobScope :54 + DJSGroup {jobSpec :: DeliveryJobSpec} + DJSMemberSupport {supportGMId :: GroupMemberId} +DeliveryJobSpec :60 + DJDeliveryJob {includePending :: Bool} + DJRelayRemoved +NewMessageDeliveryTask :122 + messageId :: MessageId + taskContext :: DeliveryTaskContext +MessageDeliveryTask :128 + taskId :: Int64 + jobScope :: DeliveryJobScope + senderGMId :: GroupMemberId + fwdSender :: FwdSender + brokerTs :: UTCTime + verifiedMsg :: VerifiedMsg 'Json +MessageDeliveryJob :161 + jobId :: Int64 + jobScope :: DeliveryJobScope + senderGMIds :: [GroupMemberId] + body :: ByteString + cursorGMId_ :: Maybe GroupMemberId +``` +Tables (`Store/SQLite/Migrations/M20250813_delivery_tasks.hs:54, :103`, `M20260529_delivery_job_senders.hs:17`; the same columns in Postgres): +``` +both tables + group_id + worker_scope + job_scope_spec_tag + job_scope_include_pending + job_scope_support_gm_id + failed + created_at + updated_at +delivery_tasks + delivery_task_id + sender_group_member_id + message_id -- messages.chat_msg_event: the tag; messages.msg_body: the original event + message_from_channel + task_status + task_err_reason +delivery_jobs + delivery_job_id + sender_group_member_ids + body + cursor_group_member_id + job_status + job_err_reason +``` +The flag, from the forwarder to the notification: +``` +forwarder agent SMQ/Agent.hs:2186-2203, 2563 one flag for each MsgReq, stored with the sent message; a body shared by VRRef is sent with the flag of each request + SMQ/Agent.hs:2637-2644 the flag is a parameter of SEND +SMP server SMQ/Server.hs:2038-2039 a notification is stored for a flagged message to a queue with notifier credentials (:2131-2136), and for such a message only + SMQ/Server.hs:361-414 the stored notification is sent as NMSG to the subscriber of the notifier, the notification server +notification server SMQ/Notifications/Server.hs:545-553 NMSG: a push notification with the last notifications of the device token is sent to the push provider (:693-702) +recipient Library/Subscriber.hs:890 the notification subscription of a member connection is created unless the chat is muted, MFNone (Library/Commands.hs:1922-1929; SMQ/Agent.hs:3345-3361) + SMQ/Agent.hs:2093-2144, 3741 the agent gets the message of the notified queue and passes MSG with the flag; forwarded content is opaque to the agent + Library/Subscriber.hs:1014 the core ignores the flag; the item is created from the forwarded event (:4008-4064) +``` +iOS notification extension, for a forwarded message: +``` +entity key NSE:489-498 id of the notified connection: "# @" of the relay or the host (apps/ios/SimpleXChat/ChatTypes.swift:3624-3632, :3258) +item key NSE:1025-1029 the member of the item, its author, for groupRcv; "#" for channelRcv +forwarded item NSE:173-187 the keys are different: the notification of the item is added to droppedNotifications, outside the entity of the notified connection +shown NSE:1196-1216 the default of the connection: MFAll: title "# :", text "message received" (apps/ios/SimpleXChat/Notifications.swift:102); MFMentions: empty content +``` +- With the flag alone the push notification is sent, and the message is received by the core of the NSE: the item is created. The shown notification is the default one. The NSE change of section 7 is required for a notification with the message text. + +Regular groups, the same message sent to a member by its author and by the host: +``` +author Library/Internal.hs:2847-2865 member with a ready connection: sent to the member; otherwise forwarded by the host (MSAForwarded) or stored as pending +host Library/Subscriber.hs:4447-4458 job recipients: members whose relation with the sender is MRIntroduced +both sent while the author is connected to the member and the relation at the host is MRIntroduced + the second copy is a duplicate: one item; the recipient sends x.grp.mem.con (Library/Internal.hs:2939-2945, 2958-2968), the host stops forwarding (Library/Subscriber.hs:3724-3729) +rule each copy of x.msg.new is flagged: two notifications for one item in that interval, one otherwise +``` + +## 2. Rule +``` +forwarded message flag = hasNotification of the tag of its original event (Protocol.hs:1338) + the flag of the received SMP message is unused +SMP message notification = True when the flag of any forwarded message of its body is True, as for sent events (Library/Internal.hs:2248, 2748) +job one value for the body, computed when the job is created; the same for every recipient, scope and cursor page +channel pieces body (id 0) and extBody, profiles with the body (id 1): the job value + overflow profile batches (id 2 and above): False; their elements are x.grp.mem.new of the relay (Library/Subscriber.hs:4277) +``` +Events (handlers at `Library/Subscriber.hs:1080-1132`; forwarded events are those of `isForwardedGroupMsg`, `Protocol.hs:528`): +``` +event flag +x.msg.new True every content message, with or without a file +x.grp.del True forwarded by the relay-removed job (:1118) +x.msg.file.descr False +x.msg.update False +x.msg.del False +x.msg.react False +x.file.cancel False +x.info False +x.grp.relay.new False +x.grp.mem.new False +x.grp.mem.role False +x.grp.mem.restrict False +x.grp.mem.del False also the relay-removed job of a removed relay or host (:3746) +x.grp.leave False also the job of a relay that leaves (Library/Commands.hs:3263) +x.grp.info False +x.grp.prefs False +x.grp.roster False +x.file True handled without a delivery task (:1096) +x.grp.mem.fwd True handled without a delivery task (:1110) +x.grp.inv True handled without a delivery task (:1124) +x.call.inv True handled without a delivery task (:1132) +x.contact True handled without a delivery task (:1132) +``` + +## 3. Core changes +``` +Protocol.hs + msgHasNotification new, next to hasNotification (:1338): VerifiedMsg e -> Bool + hasNotification of the tag of the event of the message (verifiedChatMsg, :1583) +Delivery.hs + MessageDeliveryJob (:161) notification :: Bool -- new, after body +Store/Delivery.hs + createMsgDeliveryJob (:251) new parameter notification :: Bool, before body; written to delivery_jobs.notification + MessageDeliveryJobRow (:283) BoolInt added after the body + getNextDeliveryJob (:309-328) notification selected after body; set in MessageDeliveryJob +Library/Subscriber.hs + processDeliveryTask (:4235) notification: True when msgHasNotification of verifiedMsg is True for any of acceptedTasks; computed next to senderGMIds, passed at :4237 + (:4250) msgHasNotification verifiedMsg of the task is passed + processDeliveryJob (:4331) notification is bound from the job + recipientBodyPieces (:4417) each piece is returned with its flag: overflow batch False; extBody and body: notification + addPiece (:4410-4416) MsgFlags {notification = the flag of the piece} + deliver (:4483) MsgFlags {notification} +Library/Commands.hs + leaveChannelRelay (:3263) hasNotification XGrpLeave_ is passed +Library/Internal.hs + sendFwdMemberMessage (:2903) MsgFlags {notification = msgHasNotification verifiedMsg} + False for both callers: x.grp.roster (:1307), x.grp.mem.del (Library/Subscriber.hs:3798) +``` +Sites with a fixed flag, unchanged: +``` +Library/Internal.hs:1428 sendHistory: history for a new member, with the welcome message of a group; MsgFlags False (open question 1) +Library/Internal.hs:2459 sendGroupMemberMessages: events of the user to one member; notification = True +Library/Internal.hs:2910 sendPendingGroupMessages: pending messages of the user to one member; notification = True +tests/ChatTests/Groups.hs:5681, 10907, 13320, 13452, 13608, 13701 elements sent by tests through the agent; MsgFlags False +``` +- Every other `MsgFlags` value of the repository is built for events of the user, from `hasNotification` of their tags: `Library/Internal.hs:2248, 2552, 2748`; `Library/Commands.hs:2672, 4131`; `Library/Subscriber.hs:356`. +- Forwarded content is sent as `>` elements only (`encodeFwdElement`, `Messages/Batch.hs:125`; used at `:98`, `Library/Subscriber.hs:4248`, `Library/Internal.hs:1423, 2902`). `XGrpMsgForward` events are only received (`Library/Subscriber.hs:1127`). + +## 4. Storage +Migration `M20261012_delivery_job_notification`, SQLite and Postgres, registered in `Store/SQLite/Migrations.hs` (`:180`, `:358`), `Store/Postgres/Migrations.hs` (`:57`, `:112`) and `simplex-chat.cabal` (`:172`, `:351`). It is one `ADD COLUMN`; indexes are unchanged. +``` +delivery_jobs + notification INTEGER NOT NULL DEFAULT 0 -- SMALLINT in Postgres +``` +- Down migration: the column is dropped. `delivery_tasks` is unchanged: the event of a task is read with its message. +- Applied migrations must be a prefix of the list sorted by name (`SMQ/Agent/Store/Migrations.hs:23-34`; `Store/SQLite/Migrations.hs:363`). The name is ordered after `20261010_invite_member_via_group` and before the unreleased `20261013_chat_layout` and `20261013_msg_priority` of branch `ep/llm-ux` (open question 3). +- The schema dumps and `chat_query_plans.txt` are regenerated by the tests "verify and overwrite schema dump" (`tests/SchemaDump.hs:58`, `tests/PostgresSchemaDump.hs:29`) and "verify and overwrite query plans" (`tests/SchemaDump.hs:165`); they are never edited by hand. + +## 5. Compatibility and cost +``` +chat protocol unchanged; the flag is a parameter of SMP SEND (SMQ/Protocol.hs:598) +recipient every version; the flag is used by the SMP server only +earlier relay or host sends without the flag, as now; a channel member is notified by each relay of this release +job of an earlier release notification = 0: sent without the flag +downgrade the column is dropped +traffic count and size of SMP messages unchanged +notifications one for each flagged SMP message to a queue with notifier credentials: a device with a notification token and the chat set to MFAll or MFMentions + one job is one flagged SMP message for a recipient, for all its events +channel a post is sent by each relay to each member (Library/Internal.hs:2843-2845; Store/Delivery.hs:352) + with N members and R relays up to N × R notifications are sent for a post; one item is created (Library/Internal.hs:2958-2959) + shown on iOS: one notification for a post; a duplicate copy is shown with empty content (section 7) +profile overflow batches unflagged +``` + +## 6. Tests +`tests/ChatTests/Groups.hs`, helpers next to `relayConnIdToMember` (`:13140`): +``` +agentSendBody cc connId body body sent to the connection by the agent of cc, MsgFlags False; a failure is a test failure +lastSndMsgFlags cc connId n msg_flags, "T" or "F", of the last n sent rows of type A_MSG ("M") of agent table messages for the connection, in sending order; withCCAgentTransaction (tests/ChatTests/Utils.hs:675) +lastJobNotification cc notification of the last row of delivery_jobs; withCCTransaction +``` +- The agent row of a sent message is kept after delivery until its receipt (`SMQ/Agent/Store/AgentStore.hs:1318-1349`; `SMQ/Agent.hs:2866-2869`): recipients run `/set receipts all off` before the checked messages (as `:1561`). + +New tests: +``` +group message forwarding (:191) + testGroupMsgForwardNotification createGroup3, setupGroupForwarding (:5802); bob sends a message: alice to cath "T", job True; bob edits it: "F", job False +relay delivery, single relay (:283) + testChannels1RelayDeliverNotification createChannel1Relay (:9523); alice posts: bob to cath, dan and eve "T", job True; cath adds a reaction: bob to dan and eve "F", job False +deliver member profiles via relay (:295) + testChannelOverflowProfileNotification the scenario of testChannelLargeProfileFits (:9956) with an image of 10000 characters and a message of 7000 characters, so that the profile is sent in an overflow batch: bob to dan, the rows added by the message: "F" for the overflow profile batch, "T" for the message +``` +- One sender and one message give one job. +Assertions added to present tests, on `delivery_jobs` only (the connections and their agent rows are deleted after the job, `Library/Subscriber.hs:4328`): +``` +testGroupMsgForwardGroupDeletion (:6135) alice: job True, x.grp.del +testChannelDeleteGroupSigned (:11031) bob: job True, x.grp.del +testGroupMsgForwardAdminRemoval (:6096) alice: job False, x.grp.mem.del +testChannelRelayLeave (:11220) bob: job False, x.grp.leave +``` +Present tests of delivery jobs, unchanged: "group message forwarding" (`:191-206`), "group scoped messages" (`:255-264`), "relay delivery" (`:282-292`), "deliver member profiles via relay" (`:295-301`), `testRelayBatchAllLarge` and `testRelayBatchSingletonOverflow` (`tests/MessageBatching.hs:164, 186`). + +## 7. iOS notification of a forwarded message and of a duplicate copy +Event, `Controller.hs:1048`, next to `CEvtUnknownMemberAnnounced`: +``` +CEvtDuplicateGroupMessage JSON type duplicateGroupMessage + user :: User + member :: GroupMember -- the member whose connection delivered the copy +``` +Emission, `Library/Internal.hs`, before the present handling of the duplicate, only when the chat is started with `main=off` (the NSE and the share extension): +``` +isMainApp :2976 subscriptionMode is SMSubscribe; startChatController sets SMOnlyCreate for main=off (Library/Commands.hs:235) +saveGroupRcvMsg :2925 direct copy; member: the member of the connection; every SEDuplicateGroupMessage + x.grp.mem.con to the forwarding member when the stored copy was forwarded, as now (:2940-2944) +saveGroupFwdRcvMsg :2959 channel; member: the forwarding relay; Nothing is returned, as now + :2960-2969 group; member: the forwarding host; x.grp.mem.con and the error, as now +``` +Consumers: +``` +terminal view View.hs:471: no output, as CEvtNtfMessage (:547) +API docs bots/src/API/Docs/Events.hs:205: in undocumentedEvents, as CEvtUnknownMemberCreated +apps, CLI, bots not emitted: started with main=on +NSE decoded: case duplicateGroupMessage (SimpleX NSE/NSEAPITypes.swift:91) +``` +Swift types, `apps/ios/SimpleXChat/ChatTypes.swift`: +``` +CIMeta.forwardedByMember :4253 Int64?, after editable, as in core CIMeta (Messages.hs:525); omitted in JSON for Nothing +groupMemberEntityId :3447 (groupId, groupMemberId) -> "# @"; used by GroupMember.id (:3258) and the NSE +CIDirection.groupMemberId :4224 groupMemberId of the member of groupRcv; nil for other directions +``` + +NSE, `apps/ios/SimpleX NSE/NotificationService.swift`: +``` +receivedMsgNtf (:1035-1039) + newChatItems key of a group item: groupMemberEntityId of the group and of the member whose connection delivered the item: + forwardedByMember, otherwise chatDir.groupMemberId; the id of the connection entity (ChatTypes.swift:3624-3632) + other items, and channelRcv without forwardedByMember: chatInfo.id, as now + duplicateGroupMessage key: member.id; notification: .noNtf +processReceivedNtf (:586-588): the best attempt of the entity is read from notificationEntities[id] and replaced when ntf.replaces(bestAttempt) +NSENotificationData.replaces (:77-85), new notification, best attempt: + .noNtf, .connectionEvent true: the default of the connection + .noNtf, other false + .callInvitation, any true + other, .callInvitation false + other, other true +``` +- `.noNtf` is used: `.connectionEvent` is created only as the default (`defaultBestAttemptNtf`, `:1208-1229`). A received item, a contact event and a call are never replaced by `.noNtf`. +- The best attempt is read from `notificationEntities[id]`: `processDroppedNotifications` (`:514-529`) passes the entity copied before its notifications are processed. + +Results: +``` +forwarded post, MFAll the text of the post (createMessageReceivedNtf, apps/ios/SimpleXChat/Notifications.swift:66) +forwarded post, MFMentions the text for a mention or a reply; empty content otherwise +duplicate copy empty content, unless an item of the same push notification is shown +mention, then other item the mention is shown; before this patch, .noNtf of the other item replaced it +``` +Tests, `tests/ChatTests/Groups.hs`; helpers next to `lastJobNotification`: +``` +duplicateMessageEventsCfg testCfg with an eventHook (Controller.hs:257) that records the member names of CEvtDuplicateGroupMessage +setMainAppOff cc subscriptionMode of cc set to SMOnlyCreate, as by /_start main=off; called after the group is created +``` +Copies are sent through the agent of the sender by `agentSendBody`, as in `testGroupMsgForwardOwnMembershipRejected` (`:5666`), so that the order of copies is fixed: +``` +group message forwarding (:191) + testGroupMsgForwardDuplicateEvent createGroup3; bob's message is received by cath directly; alice's agent sends a forwarded copy + (Library/Internal.hs:2960-2969): cath "duplicate group message, group id: 1"; no event + setMainAppOff cath; alice's agent sends the copy again: the same line; event member alice + testGroupMsgDirectDuplicateEvent alice's agent sends a forwarded copy; setMainAppOff cath; bob's agent sends the direct copy + (Library/Internal.hs:2939-2946): cath "duplicate group message, group id: 1"; event member bob +relay delivery, single relay (:283) + testChannelDuplicateEvent createChannel1Relay; alice posts; setMainAppOff cath; bob's agent sends a second forwarded copy + (Library/Internal.hs:2959): event member bob; cath without output +``` +- The NSE and the Swift types are not compiled on this machine. Manual checks on iOS: a post in a channel with 2 relays, with MFAll and with MFMentions; a forwarded message in a group; a mention followed by an ordinary message in one push notification. + +## Interfaces +``` +core functions msgHasNotification :: VerifiedMsg e -> Bool + createMsgDeliveryJob, new parameter notification + isMainApp :: CM Bool +core types MessageDeliveryJob.notification +core events CEvtDuplicateGroupMessage, JSON type duplicateGroupMessage +storage delivery_jobs.notification + migration M20261012_delivery_job_notification +iOS CIMeta.forwardedByMember + groupMemberEntityId + CIDirection.groupMemberId + NSEChatEvent.duplicateGroupMessage + NSENotificationData.replaces +test helpers agentSendBody + lastSndMsgFlags + lastJobNotification + duplicateMessageEventsCfg + setMainAppOff +``` +- `eventHasNotification :: ChatMsgEvent e -> Bool` of `2026-10-13-message-priority.md` is accepted by one change: `msgHasNotification` is computed as `eventHasNotification` of the event of the message. Its three uses, the job column and `hasNotification XGrpLeave_` in `leaveChannelRelay` are unchanged. +- With that change a forwarded `x.msg.update` that raises priority is flagged. The line of the priority plan, section 4, "delivery jobs of relays and forwarding hosts, MsgFlags False for every message" is replaced by this plan. + +## Open questions +1. History and the welcome message (`sendHistory`), flagged until commit `93831a0f2` (2026-07-17) and unflagged since: apply the rule? Recommended: unchanged, sent without the flag. +2. The job flag as a column, or computed by the job worker from the decoded body, without a migration? Recommended: the column, computed where the events of the tasks are read. +3. Release order with `ep/llm-ux`. Recommended: this correction first, with date `20261012` in the migration name; otherwise a date after the last released migration. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index c9a29b3ac8..205939a9a2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -170,6 +170,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group + Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification else exposed-modules: Simplex.Chat.Archive @@ -349,6 +350,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group + Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2d553bdc9b..65c70e75fe 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1046,6 +1046,7 @@ data ChatEvent | CEvtUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} | CEvtUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} | CEvtUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} + | CEvtDuplicateGroupMessage {user :: User, member :: GroupMember} | CEvtGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, msgSigned :: Maybe MsgSigStatus} | CEvtGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember, msgSigned :: Maybe MsgSigStatus} -- there is the same command response | CEvtAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs index a6ccc74247..a83c624274 100644 --- a/src/Simplex/Chat/Delivery.hs +++ b/src/Simplex/Chat/Delivery.hs @@ -163,6 +163,7 @@ data MessageDeliveryJob = MessageDeliveryJob jobScope :: DeliveryJobScope, senderGMIds :: [GroupMemberId], body :: ByteString, + notification :: Bool, cursorGMId_ :: Maybe GroupMemberId } deriving (Show) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c37c45ec90..7a4e6d2d36 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3260,7 +3260,7 @@ processChatCommand cxt nm = \case withFastStore' $ \db -> do deleteGroupDeliveryTasks db gInfo deleteGroupDeliveryJobs db gInfo - createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] body + createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] (hasNotification XGrpLeave_) body lift . void $ getDeliveryJobWorker True (groupId, DWSGroup) pure msg leaveGroupSendMsg user g@(GIK gInfo _) = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 6ea428ac1f..418d454ada 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2900,7 +2900,7 @@ sendFwdMemberMessage :: GroupMember -> GrpMsgForward -> VerifiedMsg 'Json -> CM sendFwdMemberMessage member fwd verifiedMsg = forM_ (readyMemberConn member) $ \(_, conn) -> do let body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] - void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags False, VRValue Nothing body)] + void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags {notification = msgHasNotification verifiedMsg}, VRValue Nothing body)] -- TODO ensure order - pending messages interleave with user input messages sendPendingGroupMessages :: User -> GroupInfo -> GroupMember -> Connection -> CM () @@ -2937,11 +2937,13 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) `catchAllErrors` \e -> case e of - ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do - cxt <- chatStoreCxt - fm <- withStore $ \db -> getGroupMember db cxt user groupId forwardedByGroupMemberId - forM_ (memberConn fm) $ \fmConn -> - void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId + ChatErrorStore (SEDuplicateGroupMessage _ _ _ forwardedByGroupMemberId_) -> do + unlessM isMainApp $ toView $ CEvtDuplicateGroupMessage user am' + forM_ forwardedByGroupMemberId_ $ \forwardedByGroupMemberId -> do + cxt <- chatStoreCxt + fm <- withStore $ \db -> getGroupMember db cxt user groupId forwardedByGroupMemberId + forM_ (memberConn fm) $ \fmConn -> + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId throwError e _ -> throwError e pure (am', conn', msg) @@ -2955,20 +2957,25 @@ saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMemb -- TODO [relays] 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} - | useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected - | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of - (Just authorGMId, Nothing) -> do - cxt <- chatStoreCxt - am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db cxt user groupId authorGMId - if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_ - 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@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} -> do + unlessM isMainApp $ toView $ CEvtDuplicateGroupMessage user forwardingMember + if + | useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected + | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of + (Just authorGMId, Nothing) -> do + cxt <- chatStoreCxt + am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db cxt user groupId authorGMId + if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_ + 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 +isMainApp :: CM Bool +isMainApp = (SMSubscribe ==) <$> chatReadVar subscriptionMode + 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 diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a1e748831c..9047ca1532 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -4233,8 +4233,9 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxForwardBatchLength nextTasks senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks + notification = any (\MessageDeliveryTask {verifiedMsg} -> msgHasNotification verifiedMsg) acceptedTasks withStore' $ \db -> do - forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds body + forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds notification body forM_ acceptedTasks $ \t -> updateDeliveryTaskStatus db (deliveryTaskId t) DTSProcessed forM_ largeTasks $ \t -> setDeliveryTaskErrStatus db (deliveryTaskId t) "large" when (isJust body_) . lift . void $ getDeliveryJobWorker True deliveryKey @@ -4247,7 +4248,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do fwd = GrpMsgForward {fwdSender, fwdBrokerTs} body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope [senderGMId] body + createMsgDeliveryJob db gInfo jobScope [senderGMId] (msgHasNotification verifiedMsg) body updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed lift . void $ getDeliveryJobWorker True deliveryKey @@ -4328,7 +4329,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do deleteGroupConnections user gInfo True withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete where - MessageDeliveryJob {jobId, jobScope, senderGMIds, body, cursorGMId_ = startingCursor} = job + MessageDeliveryJob {jobId, jobScope, senderGMIds, body, notification, cursorGMId_ = startingCursor} = job singleSenderGMId_ = case senderGMIds of [s] -> Just s _ -> Nothing @@ -4407,16 +4408,16 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do addRecipient acc r = case readyMemberConn r of Just (_, conn) -> snd $ foldl' (addPiece conn) (0 :: Int, acc) (recipientBodyPieces r) Nothing -> acc - addPiece conn (k, (issued, reqs)) (bid, msgBody) = + addPiece conn (k, (issued, reqs)) (bid, ntf, msgBody) = let vor | IS.member bid issued = VRRef bid | otherwise = VRValue (Just bid) msgBody issued' = IS.insert bid issued connId = if k == 0 then aConnId conn else B.empty - in (k + 1, (issued', (connId, PQEncOff, MsgFlags False, vor) : reqs)) + in (k + 1, (issued', (connId, PQEncOff, MsgFlags {notification = ntf}, vor) : reqs)) recipientBodyPieces r = - [(i, b) | (i, (b, ss)) <- overflowWithIds, any missing ss] - <> [if any missing inBodySenders then (1, extBody) else (0, body)] + [(i, False, b) | (i, (b, ss)) <- overflowWithIds, any missing ss] + <> [if any missing inBodySenders then (1, notification, extBody) else (0, notification, body)] where missing s = case M.lookup (groupMemberId' s) senderVec of Just vec -> getRelation (indexInGroup r) vec == MRNew @@ -4480,7 +4481,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do addReq conn (memIdx_, reqs) = (subtract 1 <$> memIdx_, req : reqs) where - req = (aConnId conn, PQEncOff, MsgFlags False, vrValue_) + req = (aConnId conn, PQEncOff, MsgFlags {notification}, vrValue_) vrValue_ = case memIdx_ of Nothing -> VRValue Nothing msgBody -- sending to one member, do not reference body Just 1 -> VRValue (Just 1) msgBody diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 1266104e0e..a4fb12cc72 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1346,6 +1346,11 @@ hasNotification = \case XCallInv_ -> True _ -> False +msgHasNotification :: VerifiedMsg e -> Bool +msgHasNotification verifiedMsg = hasNotification $ toCMEventTag chatMsgEvent + where + ChatMessage {chatMsgEvent} = verifiedChatMsg verifiedMsg + hasDeliveryReceipt :: CMEventTag e -> Bool hasDeliveryReceipt = \case XMsgNew_ -> True diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index c80264da7a..c8875baab2 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -248,8 +248,8 @@ deleteDoneDeliveryTasks db createdAtCutoff = do |] (createdAtCutoff, DTSProcessed, DTSError) -createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> ByteString -> IO () -createMsgDeliveryJob db gInfo jobScope senderGMIds body = do +createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> Bool -> ByteString -> IO () +createMsgDeliveryJob db gInfo jobScope senderGMIds notification body = do currentTs <- getCurrentTime DB.execute db @@ -257,10 +257,10 @@ createMsgDeliveryJob db gInfo jobScope senderGMIds body = do INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, job_status, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?) + sender_group_member_ids, body, notification, job_status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, DJSPending, currentTs, currentTs)) + ((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, BI notification, DJSPending, currentTs, currentTs)) where GroupInfo {groupId} = gInfo -- NULL ↔ []; non-empty list ↔ comma-separated decimal Int64s. @@ -280,7 +280,7 @@ getPendingDeliveryJobScopes db = |] (Only DJSPending) -type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, Maybe GroupMemberId) +type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, BoolInt, Maybe GroupMemberId) getNextDeliveryJob :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryJob)) getNextDeliveryJob db deliveryKey = do @@ -310,14 +310,14 @@ getNextDeliveryJob db deliveryKey = do SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, cursor_group_member_id + sender_group_member_ids, body, notification, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? |] (Only jobId) where toDeliveryJob :: MessageDeliveryJobRow -> Either StoreError MessageDeliveryJob - toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, cursorGMId_)) = do + toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, BI notification, cursorGMId_)) = do jobScope <- maybe (Left $ SEInvalidDeliveryJob jobId') Right $ toJobScope_ jobScopeRow -- NULL or empty string means []; otherwise the value must parse -- as a comma-separated decimal Int64 list. An unparseable @@ -325,7 +325,7 @@ getNextDeliveryJob db deliveryKey = do senderGMIds <- case senderGMIdsText_ of Nothing -> Right [] Just t -> maybe (Left $ SEInvalidDeliveryJob jobId') Right $ parseSenderGMIds t - Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, cursorGMId_} + Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, notification, cursorGMId_} parseSenderGMIds :: Text -> Maybe [GroupMemberId] parseSenderGMIds t | T.null t = Just [] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index dc2de08739..7fceb20dfe 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -55,6 +55,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json import Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings import Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group +import Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -109,7 +110,8 @@ schemaMigrations = ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), - ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), + ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs new file mode 100644 index 0000000000..b8e025f53e --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20261012_delivery_job_notification :: Text +m20261012_delivery_job_notification = + [r| +ALTER TABLE delivery_jobs ADD COLUMN notification SMALLINT NOT NULL DEFAULT 0; +|] + +down_m20261012_delivery_job_notification :: Text +down_m20261012_delivery_job_notification = + [r| +ALTER TABLE delivery_jobs DROP COLUMN notification; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 3e6d6694c6..6ed3e81d1f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -178,6 +178,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json import Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings import Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group +import Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -355,7 +356,8 @@ schemaMigrations = ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), - ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), + ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs new file mode 100644 index 0000000000..9a582823b5 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20261012_delivery_job_notification :: Query +m20261012_delivery_job_notification = + [sql| +ALTER TABLE delivery_jobs ADD COLUMN notification INTEGER NOT NULL DEFAULT 0; +|] + +down_m20261012_delivery_job_notification :: Query +down_m20261012_delivery_job_notification = + [sql| +ALTER TABLE delivery_jobs DROP COLUMN notification; +|] 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 d12b3c98cf..dc8ad0f248 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -614,15 +614,6 @@ Query: Plan: SEARCH address_ratchet_keys USING INDEX idx_address_ratchet_keys (conn_id=? AND ratchet_key_id=?) -Query: - UPDATE rcv_messages - SET receive_attempts = receive_attempts + 1 - WHERE conn_id = ? AND internal_id = ? - RETURNING receive_attempts - -Plan: -SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) - Query: DELETE FROM address_ratchet_keys WHERE conn_id = ? @@ -1431,6 +1422,10 @@ Query: SELECT last_insert_rowid() Plan: SCAN CONSTANT ROW +Query: SELECT msg_flags FROM messages WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND msg_type = ? ORDER BY internal_id DESC LIMIT ? +Plan: +SEARCH messages USING PRIMARY KEY (conn_id=?) + Query: SELECT ratchet_state FROM ratchets WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) 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 433c2ec43f..64aea20620 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -774,7 +774,7 @@ Query: SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, cursor_group_member_id + sender_group_member_ids, body, notification, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? @@ -5058,8 +5058,8 @@ Query: INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, job_status, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?) + sender_group_member_ids, body, notification, job_status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -7838,6 +7838,10 @@ Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? LIMIT 1 Plan: SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) +Query: SELECT notification FROM delivery_jobs ORDER BY delivery_job_id DESC LIMIT 1 +Plan: +SCAN delivery_jobs + Query: SELECT purchase_key FROM badge_code_redemptions Plan: SCAN badge_code_redemptions diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index d60a41047f..71abf5d37f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -806,7 +806,8 @@ CREATE TABLE delivery_jobs( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) , - sender_group_member_ids TEXT + sender_group_member_ids TEXT, + notification INTEGER NOT NULL DEFAULT 0 ) STRICT; CREATE TABLE group_member_status_predicates( member_status TEXT NOT NULL PRIMARY KEY, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e3e58cdf49..03b116202b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -469,6 +469,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] CEvtUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] CEvtUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m] + CEvtDuplicateGroupMessage {} -> [] CEvtRcvFileDescrReady _ _ _ _ -> [] CEvtRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CEvtRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a127b160f5..58c1c9c51c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,7 +18,7 @@ import ChatTests.Profiles (addTestBadge, futureDate, issueTestBadge, testBadgeKe import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) -import Control.Concurrent.STM (atomically) +import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO, writeTVar) import Control.Monad (forM_, void, when) import Control.Monad.Except (runExceptT) import Control.Monad.Reader (runReaderT) @@ -32,14 +32,14 @@ import Data.List (intercalate, isInfixOf, isSuffixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T import Simplex.Chat.Badges (FileSizeLimits (..)) -import Simplex.Chat.Controller (ChatController (ChatController, smpAgent), ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks, withAgent) +import Simplex.Chat.Controller (ChatController (ChatController, smpAgent, subscriptionMode), ChatConfig (..), ChatEvent (CEvtDuplicateGroupMessage), ChatHooks (..), ChatLogLevel (..), defaultChatHooks, withAgent) import Simplex.Chat.Library.Internal (encodeConnInfo, getShortLinkConnReq, uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options -import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), chatMsgToBody, mcSimple, msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -47,7 +47,7 @@ import Simplex.Messaging.Agent (joinConnection, prepareConnectionToJoin, sendMes import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (bbsKeyGen) import Simplex.Messaging.Client (pattern NRMInteractive) @@ -194,7 +194,10 @@ chatGroupTests = do 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 "report duplicate when forwarded copy is received after direct copy" testGroupMsgForwardDuplicateEvent + it "report duplicate when direct copy is received after forwarded copy" testGroupMsgDirectDuplicateEvent it "forward message edit (x.msg.update)" testGroupMsgForwardEdit + it "set notification flag of forwarded messages by the original event" testGroupMsgForwardNotification it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction it "forward message deletion (x.msg.del)" testGroupMsgForwardDeletion it "forward file (x.msg.file.descr)" testGroupMsgForwardFile @@ -282,6 +285,8 @@ chatGroupTests = do describe "relay delivery" $ do describe "single relay" $ do it "should deliver messages to members" testChannels1RelayDeliver + it "should set notification flag of delivered messages" testChannels1RelayDeliverNotification + it "should report duplicate message delivered by relay" testChannelDuplicateEvent describe "should deliver messages in a loop over members" $ do it "number of recipients is multiple of bucket size (3/1)" (testChannels1RelayDeliverLoop 1) it "number of recipients is NOT multiple of bucket size (3/2)" (testChannels1RelayDeliverLoop 2) @@ -298,6 +303,7 @@ chatGroupTests = do it "multi senders disseminate independently" testChannelMultiSendersIndependent it "large profile fits in body" testChannelLargeProfileFits it "multiple large profiles pack across batches in one multi-sender job" testChannelMultipleLargeProfiles + it "overflow profile batch is sent without notification flag" testChannelOverflowProfileNotification it "profile update reuses existing announcement (no re-prepend)" testChannelProfileUpdateNoRePrepend describe "channel operations" $ do it "should update channel profile (signed)" testChannelUpdateProfileSigned @@ -5912,6 +5918,53 @@ testGroupMsgForwardDeduplicate = cath <#. "#team bob> hi there" cath <# "#team hey team" +testGroupMsgForwardDuplicateEvent :: HasCallStack => TestParams -> IO () +testGroupMsgForwardDuplicateEvent ps = do + (dups, cfg) <- duplicateMessageEventsCfg + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + groupForwardingRelations alice "bob" "cath" `shouldEventuallyReturn` (MRConnected, MRConnected) + + bob #> "#team hi there" + [alice, cath] *<# "#team bob> hi there" + + sharedMsgId <- itemSharedMsgId cath + bobMemId <- memberIdByName cath "bob" + connId <- relayConnIdToMember alice "cath" + ts <- getCurrentTime + let chatMsg = ChatMessage chatInitialVRange (Just sharedMsgId) (XMsgNew $ mcSimple $ MCText "hi there") + sendForwardedCopy = agentSendBody alice connId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward (FwdMember bobMemId "bob") ts) (VMUnsigned chatMsg)] + sendForwardedCopy + cath <##. "duplicate group message, group id: 1" + readTVarIO dups `shouldReturn` [] + setMainAppOff cath + sendForwardedCopy + cath <##. "duplicate group message, group id: 1" + readTVarIO dups `shouldReturn` ["alice"] + +testGroupMsgDirectDuplicateEvent :: HasCallStack => TestParams -> IO () +testGroupMsgDirectDuplicateEvent ps = do + (dups, cfg) <- duplicateMessageEventsCfg + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + + bobMemId <- memberIdByName cath "bob" + aliceConnId <- relayConnIdToMember alice "cath" + bobConnId <- relayConnIdToMember bob "cath" + ts <- getCurrentTime + let chatMsg = ChatMessage chatInitialVRange (Just $ SharedMsgId "duplicate_msg1") (XMsgNew $ mcSimple $ MCText "hi there") + agentSendBody alice aliceConnId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward (FwdMember bobMemId "bob") ts) (VMUnsigned chatMsg)] + cath <# "#team bob> hi there [>>]" + + setMainAppOff cath + agentSendBody bob bobConnId $ chatMsgToBody chatMsg + cath <##. "duplicate group message, group id: 1" + readTVarIO dups `shouldReturn` ["bob"] + testGroupMsgForwardEdit :: HasCallStack => TestParams -> IO () testGroupMsgForwardEdit = testChat3 aliceProfile bobProfile cathProfile $ @@ -5936,6 +5989,29 @@ testGroupMsgForwardEdit = cath ##> "/tail #team 1" cath <# "#team bob> hello there [>>]" +testGroupMsgForwardNotification :: HasCallStack => TestParams -> IO () +testGroupMsgForwardNotification = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + setupGroupForwarding alice bob cath + cath ##> "/set receipts all off" + cath <## "ok" + cathConnId <- relayConnIdToMember alice "cath" + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + lastSndMsgFlags alice cathConnId 1 `shouldReturn` ["T"] + lastJobNotification alice `shouldReturn` True + + bob ##> "! #team hello there" + bob <# "#team [edited] hello there" + alice <# "#team bob> [edited] hello there" + cath <# "#team bob> [edited] hello there" + lastSndMsgFlags alice cathConnId 1 `shouldReturn` ["F"] + lastJobNotification alice `shouldReturn` False + testGroupMsgForwardReaction :: HasCallStack => TestParams -> IO () testGroupMsgForwardReaction = testChat3 aliceProfile bobProfile cathProfile $ @@ -6118,6 +6194,7 @@ testGroupMsgForwardAdminRemoval = alice <## "use /d #team to delete the group", cath <## "#team: bob removed alice from the group (signed)" ] + lastJobNotification alice `shouldReturn` False -- there is no forwarding admin anymore between bob and cath, so messages don't get delivered -- (this is not a desired behavior, just a test demonstration/proof of current implementation) @@ -6159,6 +6236,7 @@ testGroupMsgForwardGroupDeletion = cath <## "#team: bob deleted the group (signed)" cath <## "use /d #team to delete the local copy of the group" ] + lastJobNotification alice `shouldReturn` True alice ##> "/groups" alice <## "#team (group deleted, delete local copy: /d #team)" @@ -9520,6 +9598,65 @@ testChannels1RelayDeliver ps = cath <## "group ID: 1" cath <## "subscribers: 4" +testChannels1RelayDeliverNotification :: HasCallStack => TestParams -> IO () +testChannels1RelayDeliverNotification ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + forM_ [cath, dan, eve] $ \cc -> do + cc ##> "/set receipts all off" + cc <## "ok" + cathConnId <- relayConnIdToMember bob "cath" + danConnId <- relayConnIdToMember bob "dan" + eveConnId <- relayConnIdToMember bob "eve" + + alice #> "#team hi" + bob <# "#team> hi" + [cath, dan, eve] *<# "#team> hi [>>]" + forM_ [cathConnId, danConnId, eveConnId] $ \connId -> + lastSndMsgFlags bob connId 1 `shouldReturn` ["T"] + lastJobNotification bob `shouldReturn` True + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > hi" + bob <## " + 👍" + alice <# "#team cath> > hi" + alice <## " + 👍" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> > hi" + dan <## " + 👍" + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> > hi" + eve <## " + 👍" + forM_ [danConnId, eveConnId] $ \connId -> + lastSndMsgFlags bob connId 1 `shouldReturn` ["F"] + lastJobNotification bob `shouldReturn` False + +testChannelDuplicateEvent :: HasCallStack => TestParams -> IO () +testChannelDuplicateEvent ps = do + (dups, cfg) <- duplicateMessageEventsCfg + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + alice #> "#team hi" + bob <# "#team> hi" + [cath, dan, eve] *<# "#team> hi [>>]" + + sharedMsgId <- itemSharedMsgId cath + connId <- relayConnIdToMember bob "cath" + ts <- getCurrentTime + let chatMsg = ChatMessage chatInitialVRange (Just sharedMsgId) (XMsgNew $ mcSimple $ MCText "hi") + setMainAppOff cath + agentSendBody bob connId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward FwdChannel ts) (VMUnsigned chatMsg)] + readTVarIO dups `shouldEventuallyReturn` ["bob"] + createChannel1Relay :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () createChannel1Relay gName owner relay cath dan eve = do (shortLink, fullLink) <- prepareChannel1Relay gName owner relay @@ -9979,6 +10116,34 @@ testChannelLargeProfileFits ps = memberIntroducedTo bob "cath" "dan" +testChannelOverflowProfileNotification :: HasCallStack => TestParams -> IO () +testChannelOverflowProfileNotification ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] + dan ##> "/set receipts all off" + dan <## "ok" + danConnId <- relayConnIdToMember bob "dan" + + let bigImage = T.pack ("data:image/png;base64," <> replicate 10000 'A') + longText = replicate 7000 'x' + withCCTransaction bob $ \db -> + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (bigImage, "cath" :: T.Text) + sentBefore <- length <$> lastSndMsgFlags bob danConnId 100 + + cath #> ("#team " <> longText) + bob <# ("#team cath> " <> longText) + alice <# ("#team cath> " <> longText <> " [>>]") + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# ("#team cath> " <> longText <> " [>>]") + drop sentBefore <$> lastSndMsgFlags bob danConnId 100 `shouldReturn` ["F", "T"] + testChannelMultipleLargeProfiles :: HasCallStack => TestParams -> IO () testChannelMultipleLargeProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -11053,6 +11218,7 @@ testChannelDeleteGroupSigned ps = eve <## "#team: alice deleted the group (signed)" eve <## "use /d #team to delete the local copy of the group" ] + lastJobNotification bob `shouldReturn` True testChannelDeleteGroupCleanup :: HasCallStack => TestParams -> IO () testChannelDeleteGroupCleanup ps = @@ -11246,6 +11412,7 @@ testChannelRelayLeave ps = dan <## "#team: bob left the group (signed)", eve <## "#team: bob left the group (signed)" ] + lastJobNotification bob `shouldReturn` False -- verify relay1 member status is "left" on all clients that know bob checkMemberStatus alice "bob" (Just "left") @@ -13149,6 +13316,39 @@ relayConnIdToMember cc name = do (Only connId : _) -> pure connId _ -> fail $ "no relay connection to member " <> T.unpack name +agentSendBody :: TestCC -> ByteString -> ByteString -> IO () +agentSendBody cc connId body = do + let ChatController {smpAgent} = chatController cc + runExceptT (sendMessages smpAgent [(connId, PQEncOff, MsgFlags False, vrValue body)]) >>= either (fail . show) (const $ pure ()) + +lastSndMsgFlags :: TestCC -> ByteString -> Int -> IO [T.Text] +lastSndMsgFlags cc connId n = do + rows <- withCCAgentTransaction cc $ \db -> + DB.query + db + "SELECT msg_flags FROM messages WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND msg_type = ? ORDER BY internal_id DESC LIMIT ?" + (connId, Binary ("M" :: ByteString), n) + pure $ reverse $ map fromOnly rows + +lastJobNotification :: TestCC -> IO Bool +lastJobNotification cc = do + [Only (BI ntf)] <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT notification FROM delivery_jobs ORDER BY delivery_job_id DESC LIMIT 1" + pure ntf + +duplicateMessageEventsCfg :: IO (TVar [ContactName], ChatConfig) +duplicateMessageEventsCfg = do + dups <- newTVarIO [] + let hook _ evt = do + case evt of + Right (CEvtDuplicateGroupMessage _ GroupMember {localDisplayName}) -> atomically $ modifyTVar' dups (<> [localDisplayName]) + _ -> pure () + pure evt + pure (dups, testCfg {chatHooks = defaultChatHooks {eventHook = Just hook}}) + +setMainAppOff :: TestCC -> IO () +setMainAppOff cc = atomically $ writeTVar (subscriptionMode $ chatController cc) SMOnlyCreate + itemSharedMsgId :: TestCC -> IO SharedMsgId itemSharedMsgId cc = do rows <- withCCTransaction cc $ \db -> From 41f0c429b3d250249b3a748619d96b418c8de882 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 15 Oct 2026 12:50:38 +0100 Subject: [PATCH 29/31] website: update home page (#7724) * website: update home page * update --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- website/langs/ar.json | 10 +- website/langs/cs.json | 10 +- website/langs/de.json | 9 +- website/langs/en.json | 10 +- website/langs/es.json | 9 +- website/langs/fr.json | 9 +- website/langs/hu.json | 9 +- website/langs/id.json | 10 +- website/langs/it.json | 9 +- website/langs/ja.json | 10 +- website/langs/pl.json | 10 +- website/langs/pt_BR.json | 10 +- website/langs/ru.json | 10 +- website/langs/zh_Hans.json | 2 +- website/src/crowdfunding.html | 26 +- website/src/css/design3-nav.css | 14 +- website/src/css/design3.css | 367 +++++++++++++++++++++------ website/src/css/livestream.css | 2 +- website/src/fdroid.html | 5 + website/src/img/new/f_droid_icon.svg | 50 ++++ website/src/index.html | 97 +++---- website/src/js/design3.js | 5 +- website/src/livestream.html | 8 +- 23 files changed, 507 insertions(+), 194 deletions(-) create mode 100644 website/src/img/new/f_droid_icon.svg diff --git a/website/langs/ar.json b/website/langs/ar.json index 39d72effc2..92d7ddb72a 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -261,7 +261,13 @@ "about-and-contact-us": "عن واتصل بنا", "index-hero-h1": "كن
    حراً", "index-hero-h2": "في شبكتك", - "index-hero-p1": "أول شبكة بدون معرّفات مستخدمين.
    أنت تملك جهات اتصالك ومجموعاتك وقنواتك.", + "index-hero-p1": "شبكة المراسلة الوحيدة بدون أي معرّفات للمستخدمين.
    أنت تملك جهات اتصالك ومجموعاتك وقنواتك.", + "index-hero-own-part": "امتلك جزءًا منها.", + "index-hero-invest": "استثمر في SimpleX Chat.", + "index-hero-downloads": "تنزيلات", + "index-hero-monthly-users": "مستخدمون شهريًا", + "index-hero-countdown-lead": "إطلاق نطاقات SimpleX", + "index-hero-countdown-done": "احصل على نطاق SimpleX", "index-hero-download-desktop-btn-title": "نزّل تطبيق سطح مكتب SimpleX", "index-testflight-title": "إصدار SimpleX التجريبي ل iOS على TestFlight", "index-f-droid-title": "تطبيق SimpleX من خلال F-Droid", @@ -296,7 +302,7 @@ "index-roadmap-3-title": "اجعل مجتمعاتك تنمو", "index-roadmap-3-desc": "أدوات لتعزيز مجتمعاتك", "index-directory-h2": "انضم إلى مجتمعات SimpleX", - "index-directory-p1": "أكثر من 2 مليون شخص قاموا بتنزيل تطبيقات SimpleX.", + "index-directory-p1": "أكثر من 3 ملايين شخص قاموا بتنزيل تطبيقات SimpleX.", "index-directory-p2": "ابحث عن قنواتك ومجتمعاتك في الدليل وأنشئ قنواتك الخاصة!", "index-directory-cta": "اعرض دليل SimpleX", "index-directory-users-group-title": "مجموعة مستخدمي SimpleX", diff --git a/website/langs/cs.json b/website/langs/cs.json index 0df72eeab9..800578e2c4 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -262,7 +262,13 @@ "navbar-token": "Token", "index-hero-h1": "
    Žijte
    svobodně", "index-hero-h2": "Ve Své Síti", - "index-hero-p1": "První síť bez uživatelských ID.
    Vaše kontakty, skupiny a kanály patří vám.", + "index-hero-p1": "Jediná síť pro zasílání zpráv bez jakýchkoli uživatelských identifikátorů.
    Vaše kontakty, skupiny a kanály patří vám.", + "index-hero-own-part": "Vlastněte její část.", + "index-hero-invest": "Investujte do SimpleX Chat.", + "index-hero-downloads": "Stažení", + "index-hero-monthly-users": "Uživatelů měsíčně", + "index-hero-countdown-lead": "Spuštění domén SimpleX", + "index-hero-countdown-done": "Získejte doménu SimpleX", "index-hero-download-desktop-btn-title": "Stáhněte si desktopovou aplikaci SimpleX", "index-testflight-title": "Beta verze SimpleX pro iOS na TestFlight", "index-f-droid-title": "Stáhnout aplikaci SimpleX přes F-Droid", @@ -300,7 +306,7 @@ "index-roadmap-3-title": "Pomozte své komunitě růst", "index-roadmap-3-desc": "Nástroje na podporu vašich komunit", "index-directory-h2": "Zapojte se do komunit SimpleX", - "index-directory-p1": "Více než 2 miliony lidí si stáhly aplikace SimpleX.", + "index-directory-p1": "Více než 3 miliony lidí si stáhly aplikace SimpleX.", "index-directory-p2": "Najděte své kanály a komunity v katalogu a vytvořte si vlastní!", "index-directory-cta": "Zobrazit katalog SimpleX", "index-directory-users-group-title": "Uživatelské skupiny SimpleX", diff --git a/website/langs/de.json b/website/langs/de.json index 34cecb8e4d..0f144010ea 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -261,7 +261,12 @@ "directory": "Verzeichnis", "index-hero-h1": "Sei
    frei", "index-hero-h2": "In Deinem Netzwerk", - "index-hero-p1": "Das erste Netzwerk ohne Benutzer-IDs.
    Ihre Kontakte, Gruppen und Kanäle gehören Ihnen.", + "index-hero-p1": "Das einzige Messaging-Netzwerk ohne jegliche Benutzerkennungen.
    Ihre Kontakte, Gruppen und Kanäle gehören Ihnen.", + "index-hero-own-part": "Besitzen Sie einen Teil davon.", + "index-hero-downloads": "Downloads", + "index-hero-monthly-users": "Monatliche Nutzer", + "index-hero-countdown-lead": "Start der SimpleX-Domains", + "index-hero-countdown-done": "SimpleX-Domain sichern", "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", @@ -299,7 +304,7 @@ "index-roadmap-3-title": "Lassen Sie Ihre Communitys wachsen", "index-roadmap-3-desc": "Tools zur Förderung Ihrer Communitys", "index-directory-h2": "Treten Sie SimpleX-Communitys bei", - "index-directory-p1": "Mehr als 2 Millionen Nutzer haben schon SimpleX-Apps heruntergeladen.", + "index-directory-p1": "Mehr als 3 Millionen Nutzer haben schon SimpleX-Apps heruntergeladen.", "index-directory-p2": "Finden Sie Kanäle und Communitys im Verzeichnis oder erstellen Sie Ihre Eigenen!", "index-directory-cta": "SimpleX-Verzeichnis anzeigen", "index-directory-users-group-title": "SimpleX-Nutzergruppe", diff --git a/website/langs/en.json b/website/langs/en.json index 6138b34c8c..1293d83aff 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -255,6 +255,7 @@ "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:", "signing-key-fingerprint": "Signing key fingerprint (SHA-256)", + "f-droid-page-apk-text": "Or download the APK directly:", "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", @@ -264,9 +265,14 @@ "please-use-link-in-mobile-app": "Please use the link in the mobile app", "index-hero-h1": "Be
    Free", "index-hero-h2": "In Your Network", - "index-hero-p1": "The first network without user IDs.
    You own your contacts, groups and channels.", + "index-hero-p1": "The only messaging network without any user identifiers.
    You own your contacts, groups and channels.", "index-hero-invest": "Invest in SimpleX Chat.", "index-hero-invest-cta": "Learn more on Wefunder.", + "index-hero-own-part": "Own a part of it.", + "index-hero-downloads": "Downloads", + "index-hero-monthly-users": "Monthly users", + "index-hero-countdown-lead": "SimpleX domains launch", + "index-hero-countdown-done": "Get SimpleX domain", "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", @@ -304,7 +310,7 @@ "index-roadmap-3-title": "Make Your Communities Grow", "index-roadmap-3-desc": "Tools to promote your communities", "index-directory-h2": "Join SimpleX Communities", - "index-directory-p1": "More than 2 million people downloaded SimpleX apps.", + "index-directory-p1": "More than 3 million people downloaded SimpleX apps.", "index-directory-p2": "Find your channels and communities in directory and create your own!", "index-directory-cta": "View SimpleX Directory", "index-directory-users-group-title": "SimpleX users group", diff --git a/website/langs/es.json b/website/langs/es.json index 22b69cfbeb..1e9d4e6159 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -261,7 +261,12 @@ "about-and-contact-us": "Quiénes somos & Contacto", "index-hero-h1": "Sé
    Libre", "index-hero-h2": "En Tu Red", - "index-hero-p1": "La primera red sin IDs de usuario.
    Tus contactos, grupos y canales te pertenecen.", + "index-hero-p1": "La única red de mensajería sin ningún identificador de usuario.
    Tus contactos, grupos y canales te pertenecen.", + "index-hero-own-part": "Hazte dueño de una parte.", + "index-hero-downloads": "Descargas", + "index-hero-monthly-users": "Usuarios mensuales", + "index-hero-countdown-lead": "Lanzamiento de dominios SimpleX", + "index-hero-countdown-done": "Obtén un dominio SimpleX", "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", @@ -299,7 +304,7 @@ "index-roadmap-3-title": "Haz Crecer Tus Comunidades", "index-roadmap-3-desc": "Herramientas para la promoción", "index-directory-h2": "Únete a las Comunidades SimpleX", - "index-directory-p1": "Más de 2 millones de personas descargaron las aplicaciones SimpleX.", + "index-directory-p1": "Más de 3 millones de personas descargaron las aplicaciones SimpleX.", "index-directory-p2": "¡Encuentra tus canales y comunidades en el directorio y crea los tuyos!", "index-directory-cta": "Ir al Directorio SimpleX", "index-directory-users-group-title": "SimpleX users group", diff --git a/website/langs/fr.json b/website/langs/fr.json index 124c7ddc67..b53bd0a14e 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -263,7 +263,12 @@ "about-and-contact-us": "À propos & Contactez-nous", "index-hero-h1": "Soyez
    Libre", "index-hero-h2": "Dans Votre Réseau", - "index-hero-p1": "Le premier réseau sans identifiants d'utilisateur.
    Vos contacts, groupes et canaux vous appartiennent.", + "index-hero-p1": "Le seul réseau de messagerie sans aucun identifiant d'utilisateur.
    Vos contacts, groupes et canaux vous appartiennent.", + "index-hero-own-part": "Possédez-en une part.", + "index-hero-downloads": "Téléchargements", + "index-hero-monthly-users": "Utilisateurs mensuels", + "index-hero-countdown-lead": "Lancement des domaines SimpleX", + "index-hero-countdown-done": "Obtenez un domaine SimpleX", "index-hero-download-desktop-btn-title": "Téléchargez l’application de bureau SimpleX", "index-testflight-title": "Version bêta de SimpleX pour iOS sur TestFlight", "index-f-droid-title": "Application SimpleX via F-Droid", @@ -301,7 +306,7 @@ "index-token-cta-cf": "Investir dans SimpleX Chat", "index-token-p2": "Cela couvrira l'infrastructure, le développement logiciel et la gouvernance du réseau.", "index-directory-h2": "Rejoindre les communautés SimpleX", - "index-directory-p1": "Plus de 2 millions de personnes ont téléchargé les applications SimpleX.", + "index-directory-p1": "Plus de 3 millions de personnes ont téléchargé les applications SimpleX.", "index-directory-p2": "Trouvez vos chaînes et communautés dans l'annuaire et créez-en les vôtres !", "index-directory-cta": "Afficher l’annuaire SimpleX", "index-directory-users-group-title": "Groupe d'utilisateurs SimpleX", diff --git a/website/langs/hu.json b/website/langs/hu.json index 1b3a4f5869..fec3633ed9 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -260,7 +260,12 @@ "directory": "Csoportjegyzék", "about-and-contact-us": "Névjegy és kapcsolat", "index-hero-h1": "
    Legyen
    szabad
    ", - "index-hero-p1": "Az első hálózat felhasználói azonosítók nélkül.
    A saját partnerei, csoportjai és csatornái felett Ön rendelkezik.", + "index-hero-p1": "Az egyetlen üzenetküldő hálózat semmilyen felhasználói azonosító nélkül.
    A saját partnerei, csoportjai és csatornái felett Ön rendelkezik.", + "index-hero-own-part": "Legyen résztulajdonos.", + "index-hero-downloads": "Letöltés", + "index-hero-monthly-users": "Havi felhasználó", + "index-hero-countdown-lead": "A SimpleX domainek indulása", + "index-hero-countdown-done": "Szerezzen SimpleX domaint", "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", @@ -299,7 +304,7 @@ "index-roadmap-3-title": "Közösségek növelése", "index-roadmap-3-desc": "Eszközök biztosítása a közösségek népszerűsítéséhez", "index-directory-h2": "Csatlakozzon a SimpleX közösségekhez", - "index-directory-p1": "Több mint 2 millió ember töltötte le a SimpleX alkalmazások egyikét.", + "index-directory-p1": "Több mint 3 millió ember töltötte le a SimpleX alkalmazások egyikét.", "index-directory-p2": "Találja meg a kedvenc csatornáit és közösségeit a csoportjegyzékben, vagy hozza létre a sajátját!", "index-directory-cta": "SimpleX-csoportjegyzék megtekintése", "index-directory-users-group-title": "SimpleX felhasználók csoportja", diff --git a/website/langs/id.json b/website/langs/id.json index b1134bcaff..a88a5aaff3 100644 --- a/website/langs/id.json +++ b/website/langs/id.json @@ -261,7 +261,13 @@ "get-simplex": "Dapatkan aplikasi desktop SimpleX", "index-hero-h1": "
    Bebaslah
    ", "index-hero-h2": "Di Jaringan Anda", - "index-hero-p1": "Jaringan pertama tanpa ID pengguna.
    Anda pemilik kontak, grup, dan kanal Anda.", + "index-hero-p1": "Satu-satunya jaringan pesan tanpa pengenal pengguna apa pun.
    Anda pemilik kontak, grup, dan kanal Anda.", + "index-hero-own-part": "Miliki sebagian darinya.", + "index-hero-invest": "Berinvestasi di SimpleX Chat.", + "index-hero-downloads": "Unduhan", + "index-hero-monthly-users": "Pengguna bulanan", + "index-hero-countdown-lead": "Peluncuran domain SimpleX", + "index-hero-countdown-done": "Dapatkan domain SimpleX", "index-hero-download-desktop-btn-title": "Unduh Aplikasi Desktop SimpleX", "index-testflight-title": "Pratinjau iOS publik di TestFlight", "index-f-droid-title": "Aplikasi SimpleX via F-Droid", @@ -299,7 +305,7 @@ "index-roadmap-3-title": "Buat Komunitas Anda Meningkat", "index-roadmap-3-desc": "Alat untuk promosikan komunitas Anda", "index-directory-h2": "Gabung ke Komunitas SimpleX", - "index-directory-p1": "Lebih dari 2 juta orang telah mengunduh aplikasi SimpleX.", + "index-directory-p1": "Lebih dari 3 juta orang telah mengunduh aplikasi SimpleX.", "index-directory-p2": "Temukan kanal dan komunitas Anda di direktori dan buat milik Anda sendiri!", "index-directory-cta": "Lihat Direktori SimpleX", "index-directory-users-group-title": "Grup pengguna SimpleX", diff --git a/website/langs/it.json b/website/langs/it.json index 649637b8e2..013b3be7f0 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -260,7 +260,12 @@ "about-and-contact-us": "Informazioni e contatti", "directory": "Directory", "index-hero-h2": "nella tua rete", - "index-hero-p1": "La prima rete senza ID utente.
    I tuoi contatti, gruppi e canali sono tuoi.", + "index-hero-p1": "L'unica rete di messaggistica senza alcun identificatore utente.
    I tuoi contatti, gruppi e canali sono tuoi.", + "index-hero-own-part": "Possiedine una parte.", + "index-hero-downloads": "Download", + "index-hero-monthly-users": "Utenti mensili", + "index-hero-countdown-lead": "Lancio dei domini SimpleX", + "index-hero-countdown-done": "Ottieni un dominio SimpleX", "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", @@ -299,7 +304,7 @@ "index-roadmap-3-title": "Fare crescere le tue comunità", "index-roadmap-3-desc": "Strumenti per promuovere le tue comunità", "index-directory-h2": "Unisciti alle comunità di SimpleX", - "index-directory-p1": "Più di 2 milioni di persone hanno scaricato le app SimpleX.", + "index-directory-p1": "Più di 3 milioni di persone hanno scaricato le app SimpleX.", "index-directory-p2": "Trova i tuoi canali e le comunità nella directory e creane di tuoi!", "index-directory-cta": "Vedi la directory di SimpleX", "index-directory-users-group-title": "Gruppo utenti SimpleX", diff --git a/website/langs/ja.json b/website/langs/ja.json index 4c850853e4..af2e29e26f 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -261,7 +261,13 @@ "about-and-contact-us": "概要・お問い合わせ", "index-hero-h1": "自由で
    あれ", "index-hero-h2": "あなたのネットワークで", - "index-hero-p1": "ユーザーIDのない世界初のネットワーク。
    連絡先、グループ、チャンネルはあなたのものです。", + "index-hero-p1": "ユーザー識別子を一切持たない唯一のメッセージングネットワーク。
    連絡先、グループ、チャンネルはあなたのものです。", + "index-hero-own-part": "その一部を所有しよう。", + "index-hero-invest": "SimpleX Chatに投資しよう。", + "index-hero-downloads": "ダウンロード", + "index-hero-monthly-users": "月間ユーザー", + "index-hero-countdown-lead": "SimpleXドメイン公開", + "index-hero-countdown-done": "SimpleXドメインを取得", "index-hero-download-desktop-btn-title": "SimpleX デスクトップアプリをダウンロード", "index-security-assessment-title": "セキュリティ監査", "index-security-review-2022-title": "セキュリティ監査 2022", @@ -291,7 +297,7 @@ "index-roadmap-3-title": "コミュニティの成長", "index-roadmap-3-desc": "コミュニティを広げるツール", "index-directory-h2": "SimpleXコミュニティに参加する", - "index-directory-p1": "200万人以上がSimpleXアプリをダウンロードしました。", + "index-directory-p1": "300万人以上がSimpleXアプリをダウンロードしました。", "index-directory-p2": "ディレクトリでチャンネルやコミュニティを見つけて、あなた自身のものを作成しましょう!", "index-directory-cta": "SimpleXディレクトリを見る", "index-directory-users-group-title": "SimpleXユーザグループ", diff --git a/website/langs/pl.json b/website/langs/pl.json index a760dc34cf..90690c2c1c 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -262,7 +262,13 @@ "about-and-contact-us": "O nas i Kontakt", "index-hero-h1": "Bądź
    Wolny", "index-hero-h2": "W Swojej Sieci", - "index-hero-p1": "Pierwsza sieć bez identyfikatorów użytkowników.
    Twoje kontakty, grupy i kanały należą do Ciebie.", + "index-hero-p1": "Jedyna sieć komunikacyjna bez żadnych identyfikatorów użytkowników.
    Twoje kontakty, grupy i kanały należą do Ciebie.", + "index-hero-own-part": "Miej w niej udział.", + "index-hero-invest": "Zainwestuj w SimpleX Chat.", + "index-hero-downloads": "Pobrań", + "index-hero-monthly-users": "Użytkowników miesięcznie", + "index-hero-countdown-lead": "Start domen SimpleX", + "index-hero-countdown-done": "Zdobądź domenę SimpleX", "index-hero-download-desktop-btn-title": "Pobierz Aplikację SimpleX na Komputer", "index-testflight-title": "SimpleX iOS beta-wydanie na TestFlight", "index-f-droid-title": "SimpleX app z F-Droid", @@ -300,7 +306,7 @@ "index-roadmap-3-title": "Spraw, aby Twoje społeczności Rosły", "index-roadmap-3-desc": "Narzędzia do promowania Twoich społeczności", "index-directory-h2": "Dołącz do Społeczności SimpleX", - "index-directory-p1": "Ponad 2 miliony osób pobrało aplikacje SimpleX.", + "index-directory-p1": "Ponad 3 miliony osób pobrało aplikacje SimpleX.", "index-directory-p2": "Znajdź swoje kanały i społeczności w katalogu i stwórz własne!", "index-directory-cta": "Zobacz katalog SimpleX", "index-directory-users-group-title": "Grupa użytkowników SimpleX", diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 4b663e75ec..22f7a1dc45 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -262,7 +262,13 @@ "navbar-token": "Token", "index-hero-h1": "Seja
    Livre", "index-hero-h2": "Na Sua Rede", - "index-hero-p1": "A primeira rede sem IDs de usuário.
    Seus contatos, grupos e canais pertencem a você.", + "index-hero-p1": "A única rede de mensagens sem nenhum identificador de usuário.
    Seus contatos, grupos e canais pertencem a você.", + "index-hero-own-part": "Seja dono de uma parte dela.", + "index-hero-invest": "Invista no SimpleX Chat.", + "index-hero-downloads": "Downloads", + "index-hero-monthly-users": "Usuários mensais", + "index-hero-countdown-lead": "Lançamento de domínios SimpleX", + "index-hero-countdown-done": "Obtenha um domínio SimpleX", "index-hero-download-desktop-btn-title": "Baixe o aplicativo SimpleX Desktop", "index-testflight-title": "SimpleX iOS - versão beta no TestFlight", "index-f-droid-title": "Aplicativo SimpleX no F-Droid", @@ -300,7 +306,7 @@ "index-roadmap-3-title": "Faça Suas Comunidades Crescerem", "index-roadmap-3-desc": "Ferramentas para promover suas comunidades", "index-directory-h2": "Participe das Comunidades SimpleX", - "index-directory-p1": "Mais de 2 milhões de pessoas baixaram os aplicativos SimpleX.", + "index-directory-p1": "Mais de 3 milhões de pessoas baixaram os aplicativos SimpleX.", "index-directory-p2": "Encontre seus canais e comunidades no diretório e crie os seus próprios!", "index-directory-cta": "Ver diretório do SimpleX", "how-secure-comparison-title": "Comparação da segurança da criptografia de ponta a ponta em diferentes mensageiros", diff --git a/website/langs/ru.json b/website/langs/ru.json index a8bdfa8725..687e5481b2 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -261,7 +261,13 @@ "docs-dropdown-14": "SimpleX для бизнеса", "index-hero-h1": "
    Будь
    Свободен
    ", "index-hero-h2": "В Своей Сети", - "index-hero-p1": "Первая сеть без идентификаторов пользователей.
    Ваши контакты, группы и каналы принадлежат Вам.", + "index-hero-p1": "Единственная сеть обмена сообщениями без идентификаторов пользователей.
    Ваши контакты, группы и каналы принадлежат Вам.", + "index-hero-own-part": "Владейте её частью.", + "index-hero-invest": "Инвестируйте в SimpleX Chat.", + "index-hero-downloads": "Загрузок", + "index-hero-monthly-users": "Пользователей в месяц", + "index-hero-countdown-lead": "Запуск доменов SimpleX", + "index-hero-countdown-done": "Получите домен SimpleX", "index-hero-download-desktop-btn-title": "Загрузить приложение SimpleX для компьютера", "index-testflight-title": "Бета-релиз для iOS на TestFlight", "index-f-droid-title": "Загрузить через F-Droid", @@ -299,7 +305,7 @@ "index-roadmap-3-title": "Поддержка роста Ваших групп", "index-roadmap-3-desc": "Инструменты для продвижения групп", "index-directory-h2": "Вступайте в Группы SimpleX", - "index-directory-p1": "Более 2 миллионов человек скачали приложения SimpleX.", + "index-directory-p1": "Более 3 миллионов человек скачали приложения SimpleX.", "index-directory-p2": "Найдите каналы и сообщества в каталоге и создайте свои!", "index-directory-cta": "Открыть каталог SimpleX", "index-directory-users-group-title": "Группа пользователей SimpleX", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index 45cee06f5a..ac7afb074e 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -296,7 +296,7 @@ "index-roadmap-3-title": "促进社区发展", "index-roadmap-3-desc": "用于推广社区的工具", "index-directory-h2": "加入 SimpleX 社区", - "index-directory-p1": "超过 200 万人下载了 SimpleX 应用。", + "index-directory-p1": "超过 300 万人下载了 SimpleX 应用。", "index-directory-p2": "在目录中找到您的频道和社区,并创建您自己的!", "index-directory-cta": "查看 SimpleX 目录", "index-directory-users-group-title": "SimpleX 用户群组", diff --git a/website/src/crowdfunding.html b/website/src/crowdfunding.html index 3b0b293f02..11d28742b9 100644 --- a/website/src/crowdfunding.html +++ b/website/src/crowdfunding.html @@ -65,7 +65,7 @@ templateEngineOverride: njk --cf-area-off: calc((var(--sec-h) - 100svh) / 2); } - @media screen and (max-width: 959px) and (orientation: portrait) { + @media screen and (max-width: 1015px) and (orientation: portrait) { :root { --cf-grad-light: linear-gradient(30deg, #e8f3ff 0%, #c0e2ff 40%, #e9ffff 80%, #ffefd6 90%); --cf-grad-dark: linear-gradient(30deg, #000000 0%, #131d49 52%, #3f5598 65%, #c3faff 85%, #fff6e0 90%); @@ -379,7 +379,7 @@ templateEngineOverride: njk .cf-pill svg { height: 30px; width: auto; display: block; transform: translateY(-3.5px); } - @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { + @media screen and (min-width: 1016px), screen and (max-width: 1015px) and (orientation: landscape) { .cf-pill { width: 252px; justify-content: center; } } @@ -389,7 +389,7 @@ templateEngineOverride: njk .cf-cta-link:hover, .cf .text-container a.gradient-text:hover { filter: brightness(1.12); } - @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { + @media screen and (min-width: 1016px), screen and (max-width: 1015px) and (orientation: landscape) { .cf-network .text-container h2 { max-width: calc(var(--sec-vwu) * 23.5) !important; } .cf-network .text-container p { max-width: calc(var(--sec-vwu) * 31) !important; } .cf-network .text-container a { max-width: calc(var(--sec-vwu) * 31) !important; } @@ -429,7 +429,7 @@ templateEngineOverride: njk #navbar.on-light > a.logo.logo-light { display: flex; } #navbar.on-light > a.logo.logo-dark { display: none; } - @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { + @media screen and (min-width: 1016px), screen and (max-width: 1015px) and (orientation: landscape) { header#navbar { height: 74px; } header#navbar > a.logo { top: 0; @@ -443,7 +443,7 @@ templateEngineOverride: njk } /* keep the navbar visible on mobile (no slide-in menu here) */ - @media screen and (max-width: 959px) { + @media screen and (max-width: 1015px) { header#navbar { visibility: visible !important; opacity: 1 !important; @@ -451,7 +451,7 @@ templateEngineOverride: njk background: transparent !important; } } - @media screen and (max-width: 959px) and (orientation: portrait) { + @media screen and (max-width: 1015px) and (orientation: portrait) { header#navbar { height: 54px !important; } header#navbar > a.logo { top: 0 !important; height: 100% !important; align-items: center; transform: translateY(var(--cf-nav-shift)); } [dir="ltr"] header#navbar > a.logo { padding-left: var(--cf-text-pad); } @@ -459,7 +459,7 @@ templateEngineOverride: njk } /* ---- mobile: portrait art on top, text at the bottom (home page system) ---- */ - @media screen and (max-width: 959px) and (orientation: portrait) { + @media screen and (max-width: 1015px) and (orientation: portrait) { .cf .text-container h2 { font-size: calc(var(--sec-vwu) * 9.4); } .cf .text-container { margin-left: 0 !important; @@ -751,15 +751,15 @@ templateEngineOverride: njk .cf-tag .n { font-weight: 700; font-size: calc(var(--sec-vwu) * 1.5); line-height: 1.35; } .cf-tag .v { font-weight: 500; font-size: calc(var(--sec-vwu) * 1.2); line-height: 1.45; } .cf-tag .v b { font-weight: 700; } - @media screen and (min-width: 960px), screen and (max-width: 959px) and (orientation: landscape) { + @media screen and (min-width: 1016px), screen and (max-width: 1015px) and (orientation: landscape) { .cf-tags-art, .cf-art { display: none; } } - @media screen and (max-width: 959px) and (orientation: portrait) { + @media screen and (max-width: 1015px) and (orientation: portrait) { .cf-tag .n { font-size: 3.54cqh; line-height: 1.3; } } - @media screen and (max-width: 959px) and (orientation: portrait) and (max-aspect-ratio: 62/100) { + @media screen and (max-width: 1015px) and (orientation: portrait) and (max-aspect-ratio: 62/100) { .cf-hero h1 { font-size: calc(var(--sec-vwu) * 9.6); } :root { --cf-hero-text-b: calc(var(--cf-top-base) + max(0px, calc(-1 * var(--cf-area-off))) + 72.8vw); } } @@ -800,7 +800,7 @@ templateEngineOverride: njk .cf-fine-links a { color: #9fb2d8; text-decoration: none; } .cf-fine-links a:hover { color: #ffffff; } - @media screen and (max-width: 959px) and (orientation: portrait) { + @media screen and (max-width: 1015px) and (orientation: portrait) { /* padding-bottom comes from .safari-ios section.page, which a fit-content footer does not need */ section.page.cf.cf-footer { height: fit-content; min-height: fit-content; padding-bottom: 0; } @@ -935,7 +935,7 @@ templateEngineOverride: njk .cf-modal-panel { padding: 28px 20px; } .cf-form-submit { padding: 0 12px; font-size: 15px; } } - @media screen and (max-width: 959px) and (orientation: landscape) { + @media screen and (max-width: 1015px) and (orientation: landscape) { :root { --cover-bg-w: calc(min((3200 / 1920) * 100vw, (3200 / 1080) * 100svh)); --cover-bg-h: calc(min((1800 / 1920) * 100vw, (1800 / 1080) * 100svh)); @@ -1322,7 +1322,7 @@ templateEngineOverride: njk var hero = block.closest('section'); var leadShort = document.querySelector('.cf-hero .lead-short'); var logos = document.querySelectorAll('#navbar .logo img'); - var narrow = window.matchMedia('(max-width: 959px) and (orientation: portrait)'); + var narrow = window.matchMedia('(max-width: 1015px) and (orientation: portrait)'); function logoBottom() { var bottom = 0; for (var i = 0; i < logos.length; i++) { var r = logos[i].getBoundingClientRect(); if (r.height > 0) bottom = Math.max(bottom, r.bottom); } diff --git a/website/src/css/design3-nav.css b/website/src/css/design3-nav.css index f6f91a8567..1e3883caaa 100644 --- a/website/src/css/design3-nav.css +++ b/website/src/css/design3-nav.css @@ -10,7 +10,7 @@ body.change-nav-color { --nav-color: #001796; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { .logo { background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); @@ -182,7 +182,7 @@ header#navbar ul a { white-space: nowrap; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { header#navbar ul .nav-link a, header#navbar ul .nav-link-text { font-size: 18px; @@ -258,7 +258,7 @@ header#navbar ul a span svg { fill: #fff; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { header#navbar nav#menu>ul>li>a { color: var(--nav-color); } @@ -366,7 +366,7 @@ header#navbar .nav-link:focus-within .sub-menu { fill: white; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { .flag-container>a { color: var(--nav-color) !important; } @@ -427,7 +427,7 @@ header#navbar button.theme-switch-btn svg path { fill: white; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { header#navbar button.theme-switch-btn svg path { fill: var(--nav-color) !important; transition: all 0.3s ease; @@ -497,7 +497,7 @@ button#cross-btn { height: auto; } -@media (min-width: 960px) { +@media (min-width: 1016px) { @media (max-width: 1050px) { header#navbar nav#menu .nav-link.send-file { display: none; @@ -505,7 +505,7 @@ button#cross-btn { } } -@media (max-width: 959px) { +@media (max-width: 1015px) { #mobile-header { position: fixed; top: 0; diff --git a/website/src/css/design3.css b/website/src/css/design3.css index 4b645804eb..6ebbf92198 100644 --- a/website/src/css/design3.css +++ b/website/src/css/design3.css @@ -171,7 +171,7 @@ #fff6e0 90%); } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { :root { --bg-grad: linear-gradient(30deg, #e8f3ff 0%, @@ -225,7 +225,7 @@ body { background: #0a0f2b; } -@media screen and (min-width: 960px) { +@media screen and (min-width: 1016px) { html { overscroll-behavior: none; } @@ -364,55 +364,104 @@ section.page .area { /* --- COVER --- */ section.cover { + --cover-area-off: calc((var(--sec-h) - 100svh) / 2); + --cover-fade-rgb: 33, 70, 110; background-image: url("/img/design_3/cover-light.webp"); background-size: var(--cover-bg-w) var(--cover-bg-h); - background-position: center; + background-position: calc(50% + var(--sec-vwu) * 8.5) center; background-repeat: no-repeat; + overflow: hidden; z-index: 12; } .dark section.cover { + --cover-fade-rgb: 2, 7, 29; background-image: url("/img/design_3/cover.webp"); } .chrome-ios section.cover, .safari-ios section.cover { - background-position: center calc(50% - calc(110lvh - 100svh) / 2); + --cover-bottom-ext: calc(110lvh - 100svh); + background-position: calc(50% + var(--sec-vwu) * 8.5) calc(50% - calc(110lvh - 100svh) / 2); } .chrome-android section.cover { - background-position: center calc(50% - calc(100lvh - 100svh) / 2); + --cover-bottom-ext: calc(100lvh - 100svh); + background-position: calc(50% + var(--sec-vwu) * 8.5) calc(50% - calc(100lvh - 100svh) / 2); +} + +.cover-art { + position: absolute; + width: var(--sec-w); + height: var(--sec-h); + z-index: 1; + pointer-events: none; +} + +.cover-phone { + position: absolute; + right: calc(var(--sec-vwu) * 4); + bottom: calc(var(--sec-vhu) * -55.56); + height: calc(var(--sec-vhu) * 129.96); + width: auto; +} + +.cover-fade { + position: absolute; + right: calc(var(--sec-vwu) * 4); + width: calc(var(--sec-vhu) * 129.96 * 0.4914); + bottom: var(--cover-area-off); + height: calc(var(--sec-vhu) * 38); + background: linear-gradient(to top, rgb(var(--cover-fade-rgb)) 22%, rgba(var(--cover-fade-rgb), .8) 52%, rgba(var(--cover-fade-rgb), 0) 100%); } section.cover div.content { display: flex; flex-direction: column; justify-content: center; - align-items: center; - text-align: center; + align-items: flex-start; + text-align: start; gap: calc(var(--sec-vhu) * 3.5); width: var(--sec-w); height: var(--sec-h); + padding-left: calc(var(--sec-vwu) * 4); +} + +@media (min-width: 1016px), (max-width: 1015px) and (orientation: landscape) { + [dir="rtl"] section.cover div.content { + padding-left: 0; + padding-right: calc(var(--sec-vwu) * 4); + } + + [dir="rtl"] .cover-phone, + [dir="rtl"] .cover-fade { + left: calc(var(--sec-vwu) * 4); + right: auto; + } + + [dir="rtl"] .cover-stats { + left: 30px; + right: auto; + } } section.cover div.content h1 { font-family: "GT-Walsheim", sans-serif; font-weight: 700; - font-size: calc(var(--sec-vwu) * 11.7); + font-size: calc(var(--sec-vwu) * 10); line-height: 0.9; letter-spacing: -0.025em; color: #ffffff; - text-align: center; } section.cover div.content h1 .small { - font-size: calc(var(--sec-vwu) * 8); + font-size: calc(var(--sec-vwu) * 6.85); line-height: 1; margin-bottom: -10px; } section.cover div.content h1 .medium { - font-size: calc(var(--sec-vwu) * 10); + font-size: calc(var(--sec-vwu) * 8.55); line-height: 1; margin-bottom: -10px; } @@ -431,7 +480,12 @@ section.cover div.content p { font-size: calc(var(--sec-vwu) * 2.14); align-items: center; color: #ffffff; - max-width: calc(var(--sec-vwu) * 53); + max-width: calc(var(--sec-vwu) * 55); + text-wrap: balance; +} + +section.cover div.content p .nowrap { + white-space: nowrap; } section.cover div.content p.invest { @@ -444,39 +498,65 @@ section.cover div.content p.invest a { white-space: nowrap; } -.publications-btns { +html:not(.dark) section.cover div.content p { + font-weight: 300; + max-width: calc(var(--sec-vwu) * 55.2); +} + +html:not(.dark) section.cover div.content p.invest { + font-weight: 400; +} + +html:not(.dark) section.cover div.content p.invest a { + font-weight: 600; +} + +html:not(.dark) section.cover div.content .gradient-text { + background: linear-gradient(90deg, + #ffb55d 0%, + #fff494 50%, + #fbffdd 100%); + -webkit-background-clip: text; + background-clip: text; +} + +.cover-stats { position: absolute; - bottom: 24px; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 12px; -} - -[dir="ltr"] .publications-btns { - left: 30px; -} - -[dir="rtl"] .publications-btns { right: 30px; + bottom: calc(74px + var(--cover-bottom-ext, 0px)); + display: flex; + align-items: center; + gap: 48px; } -.publications-btns img { - width: 32px; +.cover-stat { + display: flex; + align-items: center; + gap: 13px; } -.publications-btns img.kuketz { - width: 38px; +.cover-stat b { + font-family: "Manrope", sans-serif; + font-weight: 700; + font-size: 37px; + line-height: 1.1; + color: #ffffff; } -.publications-btns img.whonix { - width: 30px; +.cover-stat span { + font-family: "Manrope", sans-serif; + font-weight: 400; + font-size: 18px; + color: #ffffff; +} + +.dark .cover-stat span { + color: #dfeaff; } .security-btns { position: absolute; - bottom: 24px; + bottom: calc(24px + var(--cover-bottom-ext, 0px)); display: flex; flex-wrap: wrap; align-items: center; @@ -485,13 +565,11 @@ section.cover div.content p.invest a { } [dir="ltr"] .security-btns { - left: 0; - transform: translateX(calc((100vw / 2) - 50%)); + left: 30px; } [dir="rtl"] .security-btns { - right: 0; - transform: translateX(calc(-1 * ((100vw / 2) - 50%))); + right: 30px; } .security-btns img { @@ -514,26 +592,9 @@ section.cover div.content p.invest a { text-align: right; } -@media screen and (max-width: 1279px) { - .publications-btns { - display: none; - visibility: hidden; - } - - [dir="ltr"] .security-btns { - transform: translateX(0); - left: 16px; - } - - [dir="rtl"] .security-btns { - transform: translateX(0); - right: 16px; - } -} - .socials { position: absolute; - bottom: 22px; + bottom: calc(22px + var(--cover-bottom-ext, 0px)); display: flex; align-items: center; justify-content: center; @@ -541,7 +602,7 @@ section.cover div.content p.invest a { flex-wrap: wrap; } -@media (min-width: 960px) { +@media (min-width: 1016px) { .socials .desktop-app-btn, .socials .apple-store-btn, .socials .google-play-btn { @@ -603,14 +664,7 @@ section.cover div.content p.invest a { gap: 6px; } -@media screen and (max-width: 959px) { - - .publications-btns, - .security-btns, - .socials { - position: static; - } - +@media screen and (max-width: 1015px) and (orientation: portrait) { .socials { min-height: 42px; } @@ -722,7 +776,7 @@ main .section-bg { background-image: url("/img/design_3/section-5-desktop.webp"); } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { .page-2 { background-image: url("/img/design_3/section-2-mobile-light.webp"); background-size: auto calc(var(--cover-bg-h) * 0.84); @@ -967,41 +1021,154 @@ main .section-bg { display: none; } -@media (max-width: 959px) { - section.cover div.content { - gap: calc(var(--sec-vhu) * 2.5); - transform: translateY(calc(var(--sec-vhu) * 3)); +@media (max-width: 1015px) and (orientation: portrait) { + section.cover { + --cover-crest: 10px; + --cover-text-top: calc(var(--cover-crest) + var(--cover-bg-w) * 0.0125); + --cover-text-b: calc(var(--cover-text-top) + var(--cover-bg-w) * 0.19); + --cover-phone-w: 82vw; + --cover-phone-top: calc(var(--cover-text-b) + 16px + var(--cover-area-off)); + --cover-cta-bottom: max(12px, env(safe-area-inset-bottom, 0px)); + --cover-audits-top: calc(100svh - var(--cover-cta-bottom) - 36px); + --cover-socials-top: calc(var(--cover-audits-top) - 66px); + --cover-row-gap: clamp(14px, 4.5vw, 20px); + --cover-row-start: calc(50% - (var(--cover-socials-w, 165px) + var(--cover-row-gap) + var(--cover-stats-w, 141px)) / 2); + --cover-fade-ext: max(0px, calc(110lvh - 100svh)); + --cover-fade-dyn: max(0px, calc(110lvh - 100dvh)); + --cover-fade-base: calc(var(--cover-fade-dyn) + var(--cover-cta-bottom) + 114px); + --cover-fade-ramp: 48px; } - .publications-btns, - .security-btns, - .socials { - margin-top: calc(var(--sec-vhu) * 2); + section.cover, + .chrome-ios section.cover, + .safari-ios section.cover, + .chrome-android section.cover { + background-position: 50% calc(var(--cover-crest) - var(--cover-bg-h) * 0.2678); + } + + .cover-phone { + left: 50%; + right: auto; + transform: translateX(-50%); + top: var(--cover-phone-top); + bottom: auto; + width: var(--cover-phone-w); + height: auto; + } + + .cover-fade { + left: 50%; + right: auto; + transform: translateX(-50%); + width: 100vw; + bottom: calc(var(--cover-area-off) - var(--cover-fade-ext)); + height: calc(var(--cover-fade-base) + var(--cover-fade-ramp)); + background: linear-gradient(to top, rgba(var(--cover-fade-rgb), .9) var(--cover-fade-base), rgba(var(--cover-fade-rgb), 0) 100%); + } + + [dir="ltr"] .socials { + top: var(--cover-socials-top); + bottom: auto; + left: var(--cover-row-start); + right: auto; + } + + [dir="rtl"] .socials { + top: var(--cover-socials-top); + bottom: auto; + left: auto; + right: var(--cover-row-start); + } + + [dir="ltr"] .cover-stats { + top: var(--cover-socials-top); + bottom: auto; + left: calc(var(--cover-row-start) + var(--cover-socials-w, 165px) + var(--cover-row-gap)); + right: auto; + } + + [dir="rtl"] .cover-stats { + top: var(--cover-socials-top); + bottom: auto; + left: auto; + right: calc(var(--cover-row-start) + var(--cover-socials-w, 165px) + var(--cover-row-gap)); + } + + [dir="ltr"] .security-btns, + [dir="rtl"] .security-btns { + top: var(--cover-audits-top); + bottom: auto; + left: 0; + right: 0; + } +} + +@media (max-width: 1015px) and (orientation: landscape) { + section.cover { + --cover-bg-w: calc(min((3200 / 1920) * 100vw, (3200 / 1080) * 100svh)); + --cover-bg-h: calc(min((1800 / 1920) * 100vw, (1800 / 1080) * 100svh)); + --sec-w: calc(min(100vw, (1920 / 1080) * 100svh)); + --sec-h: calc(min((1080 / 1920) * 100vw, 100svh)); + --sec-vwu: calc(var(--sec-w) / 100); + --sec-vhu: calc(var(--sec-h) / 100); + } +} + +@media (max-width: 1015px) and (orientation: landscape) and (max-height: 500px) { + section.cover div.content h1 br { + display: none; + } + + .cover-stats { + gap: 24px; + } + + .cover-stat { + gap: 8px; + } + + .cover-stat b { + font-size: 24px; + } + + .cover-stat span { + font-size: 13px; + } +} + +@media (max-width: 1015px) and (orientation: portrait) { + section.cover div.content { + justify-content: flex-start; + align-items: center; + text-align: center; + gap: calc(var(--sec-vwu) * 3); + margin-top: min(0px, calc(var(--cover-text-top) + var(--cover-area-off))); + padding: max(0px, calc(var(--cover-text-top) + var(--cover-area-off))) 21.6px 0; } section.cover div.content h1 { line-height: 0.85; font-weight: 700; - font-size: calc(var(--sec-vwu) * 30); + font-size: calc(var(--sec-vwu) * 24.75); } section.cover div.content h1 .small { - font-size: calc(var(--sec-vwu) * 16); + font-size: calc(var(--sec-vwu) * 13.2); } section.cover div.content h1 .medium { - font-size: calc(var(--sec-vwu) * 24); + font-size: calc(var(--sec-vwu) * 19.8); } section.cover div.content h2 { font-weight: 500; - font-size: calc(var(--sec-vwu) * 12.85); + font-size: calc(var(--sec-vwu) * 10.6); } section.cover div.content p { font-weight: 400; - font-size: calc(var(--sec-vwu) * 5.5); - max-width: calc(var(--sec-vwu) * 93); + font-size: calc(var(--sec-vwu) * 4.1); + max-width: none; } section.cover div.content p.invest { @@ -1012,6 +1179,40 @@ main .section-bg { font-weight: 600; } + html:not(.dark) section.cover div.content p, + html:not(.dark) section.cover div.content p.invest { + font-weight: 500; + max-width: none; + } + + html:not(.dark) section.cover div.content p.invest a { + font-weight: 700; + } + + .cover-stats { + height: 42px; + gap: clamp(12px, 4vw, 16px); + } + + .cover-stat { + display: block; + text-align: start; + } + + .cover-stat b { + display: block; + font-size: clamp(19px, 5.6vw, 22px); + line-height: 1.05; + } + + .cover-stat span { + display: block; + font-size: clamp(10px, 2.8vw, 11px); + line-height: 1.25; + } +} + +@media (max-width: 1015px) { /* --- MAIN SECTIONS --- */ .page .text-container { justify-content: flex-end; @@ -1108,7 +1309,7 @@ main .section-bg { border-radius: calc(var(--sec-vhu) * 1.8); } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { .page-6 .group-images { width: 100%; height: 10%; @@ -1321,7 +1522,7 @@ main .section-bg { margin: calc(var(--sec-vhu) * 2); } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { .page-6 .group-images>a.group-image:nth-child(1) { --c: 3; --r: 1; @@ -1525,7 +1726,7 @@ section.page.footer { min-height: fit-content; } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { section.page.footer { height: 100lvh; min-height: 100lvh; @@ -1560,7 +1761,7 @@ section.page.footer { } } -@media (min-width: 960px) { +@media (min-width: 1016px) { .footer .container-md, .footer .container-sm, diff --git a/website/src/css/livestream.css b/website/src/css/livestream.css index 831f4a0569..bb8ab8d373 100644 --- a/website/src/css/livestream.css +++ b/website/src/css/livestream.css @@ -126,7 +126,7 @@ main .section-bg { color: #000000; } -@media screen and (max-width: 959px) { +@media screen and (max-width: 1015px) { .livestream { background-image: url("/img/design_3/section-4-mobile-light.webp"); background-size: auto calc(var(--cover-bg-h) * 0.78); diff --git a/website/src/fdroid.html b/website/src/fdroid.html index 67e4821e20..c3441d9680 100644 --- a/website/src/fdroid.html +++ b/website/src/fdroid.html @@ -38,6 +38,11 @@ templateEngineOverride: njk 9F 35 8F F2 84 D1 F7 16 56 A2 BF AF 0E 00 5D EA E6 AA 14 14 37 20 E0 89 F1 1F F2 DD CF EB 01 BA

    +

    + {{ "f-droid-page-apk-text" | i18n({}, lang ) | safe }} + aarch64, + armv7 +