diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c3ef9fa088..b89f6ccce0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -10,17 +10,25 @@ on:
- "!*-fdroid"
- "!*-armv7a"
pull_request:
- paths-ignore:
- - "apps/ios"
- - "apps/multiplatform"
- - "blog"
- - "docs"
- - "fastlane"
- - "images"
- - "packages"
- - "website"
- - "README.md"
- - "PRIVACY.md"
+ paths:
+ - "src/**"
+ - "apps/simplex-chat/**"
+ - "apps/simplex-bot/**"
+ - "apps/simplex-bot-advanced/**"
+ - "apps/simplex-broadcast-bot/**"
+ - "apps/simplex-directory-service/**"
+ - "tests/**"
+ - "bots/src/**"
+ - "simplex-chat.cabal"
+ - "cabal.project"
+ - "Dockerfile*"
+ - "scripts/ci/**"
+ - "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:
#
@@ -294,6 +302,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: docker exec -t builder sh -eu {0}
run: |
+ export ASSETS_DIR='../../assets'
scripts/desktop/make-deb-linux.sh
- name: Prepare Desktop
@@ -319,6 +328,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
shell: docker exec -t builder sh -eu {0}
run: |
+ export ASSETS_DIR='../../assets'
scripts/desktop/make-appimage-linux.sh
- name: Prepare AppImage
@@ -369,6 +379,100 @@ jobs:
exit 1
fi
+# =================================
+# Linux PostgreSQL Library Build
+# =================================
+
+ build-linux-postgres:
+ 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')
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v3
+
+ - name: Get UID and GID
+ id: ids
+ run: |
+ echo "uid=$(id -u)" >> $GITHUB_OUTPUT
+ echo "gid=$(id -g)" >> $GITHUB_OUTPUT
+
+ - name: Free disk space
+ shell: bash
+ run: ./scripts/ci/linux_util_free_space.sh
+
+ - name: Restore cached build
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cabal/store
+ dist-newstyle
+ key: ubuntu-22.04-x86_64-postgres-ghc${{ needs.variables.outputs.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
+
+ - name: Set up Docker Buildx
+ uses: simplex-chat/docker-setup-buildx-action@v3
+
+ - name: Build and cache Docker image
+ uses: simplex-chat/docker-build-push-action@v6
+ with:
+ context: .
+ load: true
+ file: Dockerfile.build
+ tags: build/22.04:latest
+ build-args: |
+ TAG=22.04
+ HASH=sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5
+ GHC=${{ needs.variables.outputs.GHC_VER }}
+ USER_UID=${{ steps.ids.outputs.uid }}
+ USER_GID=${{ steps.ids.outputs.gid }}
+
+ - name: Start container
+ shell: bash
+ run: |
+ docker run -t -d \
+ --name builder \
+ -v ~/.cabal:/root/.cabal \
+ -v /home/runner/work/_temp:/home/runner/work/_temp \
+ -v ${{ github.workspace }}:/project \
+ build/22.04:latest
+
+ - name: Prepare cabal.project.local
+ shell: bash
+ run: |
+ echo "ignore-project: False" >> cabal.project.local
+ echo "package direct-sqlcipher" >> cabal.project.local
+ echo " flags: +openssl" >> cabal.project.local
+
+ - name: Build postgres library
+ shell: docker exec -t builder sh -eu {0}
+ run: |
+ cabal clean
+ cabal update
+ scripts/desktop/build-lib-linux.sh postgres
+
+ - name: Copy libs from container
+ shell: bash
+ run: |
+ 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/
+
+ - name: Upload postgres libs artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: simplex-libs-linux-postgres-x86_64
+ path: postgres-libs/
+
+ - name: Fix permissions for cache
+ shell: bash
+ run: |
+ sudo chmod -R 777 dist-newstyle ~/.cabal
+ sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal
+
# =========================
# MacOS Build
# =========================
@@ -447,6 +551,7 @@ jobs:
APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
run: |
+ export ASSETS_DIR='../../assets'
scripts/ci/build-desktop-mac.sh
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
echo "package_path=$path" >> $GITHUB_OUTPUT
@@ -573,7 +678,7 @@ jobs:
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
scripts/desktop/build-lib-windows.sh
cd apps/multiplatform
- ./gradlew packageMsi
+ ./gradlew -Psimplex.assets.dir=../../assets packageMsi
rm -rf dist-newstyle/src/direct-sq*
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
echo "package_path=$path" >> $GITHUB_OUTPUT
@@ -605,7 +710,7 @@ jobs:
release-nodejs-libs:
runs-on: ubuntu-latest
- needs: [build-linux, build-macos]
+ needs: [build-linux, build-linux-postgres, build-macos]
if: startsWith(github.ref, 'refs/tags/v') && (!cancelled())
steps:
- name: Checkout current repository
@@ -614,6 +719,13 @@ jobs:
- 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'
@@ -670,6 +782,17 @@ jobs:
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:
diff --git a/PRIVACY.md b/PRIVACY.md
index 18e5539726..5713f8e134 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -27,17 +27,17 @@ permalink: /privacy/index.html
SimpleX Chat (also referred to as SimpleX) is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
-SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts.
+SimpleX messaging protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX apps allow their users to send messages and files via relay server infrastructure. Relay server owners and operators do not have any access to your messages, thanks to quantum-resistant double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not host user accounts.
Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
-If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
+If you believe that any part of this document is not aligned with SimpleX network mission or values, please raise it via [email](mailto:chat@simplex.chat) or [chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw).
## Privacy Policy
### General principles
-SimpleX network software uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from being compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack).
+SimpleX network software uses established industry practices for security and encryption to provide secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption is protected from [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack) by the relay servers, even if they are modified or compromised.
SimpleX software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
@@ -45,7 +45,7 @@ SimpleX software is similar in its design approach to email clients and browsers
SimpleX network operators are not communication service provider, and provide public relays "as is", as experimental, without any guarantees of availability or data retention. The operators of the relay servers preset in the app ("Preset Server Operators"), including SimpleX Chat Ltd, are committed to maintain a high level of availability, reliability and security. SimpleX client apps can have multiple preset relay server operators that you can opt-in or opt-out of using. You are and will continue to be able to use any other operators or your own servers.
-SimpleX network design is based on the principles of users and data sovereignty, and device and operator portability.
+SimpleX network design is based on the principles of user and data sovereignty, and device and operator portability.
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
@@ -69,22 +69,26 @@ Your message history is stored only on your own device and the devices of your c
#### Private message delivery
-You do not have control over which servers are used to send messages to your contacts - these servers are chosen by your contacts. To send messages your client by default uses configured servers to forward messages to the destination servers, thus protecting your IP address from the servers chosen by your contacts.
+You do not have control over which servers are used to receive messages by your contacts - these servers are chosen by your contacts. To send messages your client by default uses configured servers to forward messages to the destination servers, thus protecting your IP address from the servers chosen by your contacts.
In case you use preset servers of more than one operator, the app will prefer to use a server of an operator different from the operator of the destination server to forward messages, preventing destination server to correlate messages as belonging to one client.
+Preset servers do not log IP addresses of the user devices that connect to them.
+
You can additionally use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by you.
*Please note*: the clients allow changing configuration to connect to the destination servers directly. It is not recommended - if you make such change, your IP address will be visible to the destination servers.
#### Storage of messages and files on the servers
-The messages are removed from the relay servers as soon as all messages of the file they were stored in are delivered and saving new messages switches to another file, as long as these servers use unmodified published code. Undelivered messages are also marked as delivered after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
+The messages stored on the servers are end-to-end encrypted, and cannot be read by server owners.
+
+The messages are irreversibly removed from the preset relay servers as soon as they are delivered or after 21 days.
+
+Other relay servers may use message logs that would result in longer storage of delivered messages, until the log file is rotated, which normally should happen within one month if servers use the same code as preset servers.
The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers).
-The encrypted messages can be stored for some time after they are delivered or expired (because servers use append-only logs for message storage). This time varies, and may be longer in connections with fewer messages, but it is usually limited to 1 month, including any backup storage.
-
#### Connections with other users
When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers, or, if available, the relays of two different operators, for increased privacy, in case you have more than one relay server configured in the app, which is the default.
@@ -93,15 +97,11 @@ Preset and unmodified SimpleX relay servers do not store information about which
#### Connection links privacy
-When you create a connection with another user, the app generates a link/QR code that can be shared with the user to establish the connection via any channel (email, any other messenger, or a video call). This link is safe to share via insecure channels, as long as you can identify the recipient and also trust that this channel did not replace this link (to mitigate the latter risk you can validate the security code via the app).
+When you create a connection with another user, the app generates a one-time link/QR code that can be shared with the user to establish the connection via any channel (email, any other messenger, or a video call). This link is safe to share via insecure channels, as long as you can identify the recipient and also trust that this channel did not replace this link (to mitigate the latter risk you can validate the security code via the app).
-While the connection "links" contain SimpleX Chat Ltd domain name `simplex.chat`, this site is never accessed by the app, and is only used for these purposes:
-- to direct the new users to the app download instructions,
-- to show connection QR code that can be scanned via the app,
-- to "namespace" these links,
-- to open links directly in the installed app when it is clicked outside of the app.
+The connection link contains the address of the server used to establish the connection. Your profile name and picture are stored on this server in encrypted form until your contact uses the link, after which this data is removed. The server cannot access this data without the link.
-You can always safely replace the initial part of the link `https://simplex.chat/` either with `simplex:/` (which is a URI scheme provisionally registered with IANA) or with any other domain name where you can self-host the app download instructions and show the connection QR code (but in case it is your domain, it will not open in the app). Also, while the page renders QR code, all the information needed to render it is only available to the browser, as the part of the "link" after `#` symbol is not sent to the website server.
+The old connection "links" contained SimpleX Chat Ltd domain name `simplex.chat`, but this site is never accessed by the app - you could replace the initial part of the old link `https://simplex.chat/` either with `simplex:/` or with any other domain name.
#### iOS Push Notifications
@@ -117,11 +117,15 @@ You can read more about the design of iOS push notifications [here](./blog/20220
Additional technical information can be stored on the network servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX network design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
+Because SimpleX servers do not create user accounts and do not store any identifiers linking transport data to message queues or user profiles, this technical data cannot be used by server operators to identify any person.
+
#### SimpleX Directory
-This section applies only to the experimental group directory operated by SimpleX Chat Ltd.
+This section applies only to the experimental group directory chat bot operated by SimpleX Chat Ltd.
-[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
+[SimpleX Directory](/docs/DIRECTORY.md) bot stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok).
+
+You can also view the groups registered in SimpleX directory via the browser at [simplex.chat/directory](https://simplex.chat/directory)
#### Public groups and content channels
@@ -131,15 +135,23 @@ You may participate in a public group and receive content from a public channel
- to retain a copy of your messages according to the Group settings (e.g., the Group may allow irreversible message deletion from the recipient devices for a limited period of time, or it may only allow to edit and mark messages as deleted on recipient devices). Deleting message from the recipient devices or marking message as deleted revokes the license to share the message.
- to Group owners: to share your messages with the new Group members as history of the Group. Currently, the Group history shared with the new members is limited to 100 messages.
-Group owners may use chat relays or automated bots (Chat Relays) to re-broadcast member messages to all members, for efficiency. The Chat Relays may be operated by the group owners, by preset operators or by 3rd parties. The Chat Relays have access to and will retain messages in line with Group settings, for technical functioning of the Group. Neither you nor group owners grant any content license to Chat Relay operators.
+#### Public channels and chat relays (beta)
+
+Public channels are experimental - their functionality and privacy properties may change.
+
+Channel owners use chat relays that retain messages to deliver them to channel subscribers. The chat relays may be operated by the channel owners, by preset operators or by 3rd parties. The chat relays are client applications on SimpleX network - they cannot identify subscribers. Neither you nor channel owners grant any content license to chat relay operators.
+
+#### Public contact, group and channel addresses
+
+Public addresses contain profile name, picture and other profile details. This data is encrypted on the servers, and can only be accessed via the address. Server operators cannot list addresses and cannot access this data without having the address. Public address data remains on the servers until removed by the user via the app. If you lose access to the app without a backup, server operators have no way to verify address ownership and can only remove addresses following due process.
#### User Support
-The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
+The app includes support contact operated by SimpleX Chat Ltd. If you contact support, any personal data you share is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw) when it is possible, and avoid sharing any personal information.
### Preset Server Operators
-Preset server operators will not share the information on their servers with each other, other than aggregate usage statistics.
+Preset server operators will not share the information or any metadata on their servers with each other, other than aggregate usage statistics.
Preset server operators must not provide general access to their servers or the data on their servers to each other.
@@ -149,7 +161,7 @@ Preset server operators will provide non-administrative access to control port o
The preset server operators use third parties. While they do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via the servers. Hosting and network providers can also store IP addresses and other transport information as part of their logs.
-SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
+SimpleX Chat Ltd uses a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, please contact us via SimpleX Chat apps or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/vks/v1/by-fingerprint/FB44AF81A45BDE327319797C85107E357D4A17FC).
The cases when the preset server operators may share the data temporarily stored on the servers:
@@ -158,9 +170,7 @@ The cases when the preset server operators may share the data temporarily stored
- To detect, prevent, or otherwise address fraud, security, or technical issues.
- To protect against harm to the rights, property, or safety of software users, operators of preset servers, or the public as required or permitted by law.
-By the time of updating this document, the preset server operators were not served with any enforceable requests and did not provide any information from the servers to any third parties. If the preset server operators are ever requested to provide such access or information, they will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
-
-Preset server operators will publish information they are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
+Reports on requests for user data are published in [Transparency reports](./docs/TRANSPARENCY.md). To date, no user information was provided in response to any requests. If the preset server operators are ever required to provide information, they will follow the due legal process to limit any information shared to the minimally required by law.
### Source code license
@@ -168,6 +178,8 @@ As this software is fully open-source and provided under AGPLv3 license, all inf
In addition to the AGPLv3 license terms, the preset relay server operators are committed to the software users that these servers will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
+Users can independently [reproduce builds](./docs/REPRODUCE.md) to verify that the published client and server binaries were compiled from the published code.
+
### Updates
This Privacy Policy applies to SimpleX Chat Ltd and all other preset server operators you use in the app.
@@ -176,7 +188,7 @@ This Privacy Policy may be updated as needed so that it is current, accurate, an
Please also read The Conditions of Use of Software and Infrastructure below.
-If you have questions about this Privacy Policy please contact SimpleX Chat Ltd via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
+If you have questions about this Privacy Policy or data protection please contact SimpleX Chat Ltd (company number 13691484, registered at 20-22 Wenlock Road, London, United Kingdom N1 7GU) via [email](mailto:chat@simplex.chat) or [chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw).
## Conditions of Use of Software and Infrastructure
@@ -188,6 +200,8 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
**Client applications**. SimpleX Chat client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on SimpleX Chat code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any tracking information with SimpleX Chat Ltd, preset server operators or any other third parties. If you ever discover any tracking or analytics code, please report it to SimpleX Chat Ltd, so it can be removed.
+Client applications must not include any code that could compromise the security of end-to-end encryption of files and messages. Client applications must not send anything not directly required for users communications without explicit users' consent.
+
**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to the [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about the privacy model and known security and privacy risks.
**Privacy of user data**. Servers do not retain any data you transmit for any longer than necessary to deliver the messages between apps. Preset server operators collect aggregate statistics across all their servers, as supported by published code and can be enabled by any infrastructure operator, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. SimpleX Chat Ltd does not have information about how many people use SimpleX Chat applications, it only knows an approximate number of app installations and the aggregate traffic through the preset servers. In any case, preset server operators do not and will not sell or in any way monetize user data. The future business model assumes charging for some optional Software features instead, in a transparent and fair way.
@@ -243,4 +257,4 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd and preset server operators at any time by deleting the Applications from your devices and discontinuing use of the Infrastructure of SimpleX Chat Ltd and preset server operators. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd and/or preset server operators.
-Updated March 3, 2025
+Updated April 18, 2026
diff --git a/README.md b/README.md
index 818ed7142f..252fc95708 100644
--- a/README.md
+++ b/README.md
@@ -425,9 +425,9 @@ Please do NOT report security vulnerabilities via GitHub issues.
## License
-This software is licensed under the GNU Affero General Public License version 3 (AGPLv3). See the [LICENSE](./LICENSE) file for details. The SimpleX and SimpleX Chat name, logo, and associated branding materials are not covered by this license and are subject to the terms outlined in the [TRADEMARK](./docs/TRADEMARK.md) file.
+This software is licensed under the GNU Affero General Public License version 3 (AGPLv3). See the [LICENSE](./LICENSE) file for details. The SimpleX and SimpleX Chat name, logo, associated branding materials, and application and website graphic assets (illustrations, images, visual designs, etc.) are not covered by this license and are subject to the terms outlined in the [TRADEMARK](./docs/TRADEMARK.md) and [ASSETS_LICENSE](./assets/ASSETS_LICENSE.md) files respectively.
-Graphic designs, artworks and layouts are not licensed for re-use. If you want to use them in your publications, please ask for permission. Texts can be used as direct quotes, referencing the source.
+If you want to use any graphic assets in your publications, please ask for permission. Texts can be used as direct quotes, referencing the source.
[
](https://apps.apple.com/us/app/simplex-chat/id1605771084)
diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore
index 3d152a0610..ea8e911891 100644
--- a/apps/ios/.gitignore
+++ b/apps/ios/.gitignore
@@ -69,3 +69,7 @@ Libraries/
Shared/MyPlayground.playground/*
testpush.sh
+
+# Local build config and generated assets
+Local.xcconfig
+Shared/SimpleXAssets.xcassets/*.imageset
diff --git a/apps/ios/Debug.xcconfig b/apps/ios/Debug.xcconfig
new file mode 100644
index 0000000000..7f0389c760
--- /dev/null
+++ b/apps/ios/Debug.xcconfig
@@ -0,0 +1,2 @@
+SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+#include? "Local.xcconfig"
diff --git a/apps/ios/README.md b/apps/ios/README.md
index fb6a6ed40d..1e987f655e 100644
--- a/apps/ios/README.md
+++ b/apps/ios/README.md
@@ -85,6 +85,27 @@ Workflow:
- `Product > Export Localizations` - Export XLIFF files
- `Product > Import Localizations` - Import updated translations
+## SimpleX Assets
+
+The app includes optional assets behind the `SIMPLEX_ASSETS` Swift compilation flag. Without setup, the app builds normally without them.
+
+### Setup
+
+Create `Local.xcconfig` (gitignored) in the `apps/ios/` directory:
+```
+SIMPLEX_ASSETS_DIR = /path/to/assets
+SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) SIMPLEX_ASSETS
+```
+
+The copy script (`scripts/ios/copy-assets.sh`) runs as a build phase on each build but exits immediately if `SIMPLEX_ASSETS` is not set.
+
+### Updating assets
+
+When source images change, regenerate resized images (requires ImageMagick):
+```bash
+cd path/to/assets && ./resize.sh
+```
+
## Background Capabilities
Configured in Info.plist:
diff --git a/apps/ios/Release.xcconfig b/apps/ios/Release.xcconfig
new file mode 100644
index 0000000000..234f81e782
--- /dev/null
+++ b/apps/ios/Release.xcconfig
@@ -0,0 +1 @@
+#include? "Local.xcconfig"
diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift
index 1131069d88..547c2b7000 100644
--- a/apps/ios/Shared/Model/AppAPITypes.swift
+++ b/apps/ios/Shared/Model/AppAPITypes.swift
@@ -62,6 +62,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction)
case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64])
case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?)
+ case apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool)
case apiGetNtfToken
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
@@ -72,6 +73,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
case apiNewPublicGroup(userId: Int64, incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile)
case apiGetGroupRelays(groupId: Int64)
+ case apiAddGroupRelays(groupId: Int64, relayIds: [Int64])
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
case apiJoinGroup(groupId: Int64)
case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole)
@@ -128,7 +130,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiAddContact(userId: Int64, incognito: Bool)
case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
case apiChangeConnectionUser(connId: Int64, userId: Int64)
- case apiConnectPlan(userId: Int64, connLink: String)
+ case apiConnectPlan(userId: Int64, connLink: String, linkOwnerSig: LinkOwnerSig?)
case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData)
case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData)
case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64)
@@ -261,6 +263,9 @@ enum ChatCommand: ChatCmdProtocol {
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
let asGroup = sendAsGroup ? " as_group=on" : ""
return "/_forward \(ref(toChatType, toChatId, scope: toScope))\(asGroup) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
+ case let .apiShareChatMsgContent(shareChatType, shareChatId, toChatType, toChatId, toScope, sendAsGroup):
+ let asGroup = sendAsGroup ? "(as_group=on)" : ""
+ return "/_share chat content \(ref(shareChatType, shareChatId, scope: nil)) \(ref(toChatType, toChatId, scope: toScope))\(asGroup)"
case .apiGetNtfToken: return "/_ntf get "
case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)"
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
@@ -271,6 +276,7 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
case let .apiNewPublicGroup(userId, incognito, relayIds, groupProfile): return "/_public group \(userId) incognito=\(onOff(incognito)) \(relayIds.map(String.init).joined(separator: ",")) \(encodeJSON(groupProfile))"
case let .apiGetGroupRelays(groupId): return "/_get relays #\(groupId)"
+ case let .apiAddGroupRelays(groupId, relayIds): return "/_add relays #\(groupId) \(relayIds.map(String.init).joined(separator: ","))"
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)"
@@ -337,7 +343,9 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))"
case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))"
case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)"
- case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)"
+ case let .apiConnectPlan(userId, connLink, linkOwnerSig):
+ let sigStr = if let linkOwnerSig { " sig=\(encodeJSON(linkOwnerSig))" } else { "" }
+ return "/_connect plan \(userId) \(connLink)\(sigStr)"
case let .apiPrepareContact(userId, connLink, contactShortLinkData): return "/_prepare contact \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(contactShortLinkData))"
case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") direct=\(onOff(directLink)) \(encodeJSON(groupShortLinkData))"
case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)"
@@ -451,6 +459,7 @@ enum ChatCommand: ChatCmdProtocol {
case .apiGetReactionMembers: return "apiGetReactionMembers"
case .apiPlanForwardChatItems: return "apiPlanForwardChatItems"
case .apiForwardChatItems: return "apiForwardChatItems"
+ case .apiShareChatMsgContent: return "apiShareChatMsgContent"
case .apiGetNtfToken: return "apiGetNtfToken"
case .apiRegisterToken: return "apiRegisterToken"
case .apiVerifyToken: return "apiVerifyToken"
@@ -461,6 +470,7 @@ enum ChatCommand: ChatCmdProtocol {
case .apiNewGroup: return "apiNewGroup"
case .apiNewPublicGroup: return "apiNewPublicGroup"
case .apiGetGroupRelays: return "apiGetGroupRelays"
+ case .apiAddGroupRelays: return "apiAddGroupRelays"
case .apiAddMember: return "apiAddMember"
case .apiJoinGroup: return "apiJoinGroup"
case .apiAcceptMember: return "apiAcceptMember"
@@ -821,6 +831,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case acceptingContactRequest(user: UserRef, contact: Contact)
case contactRequestRejected(user: UserRef, contactRequest: UserContactRequest, contact_: Contact?)
case newChatItems(user: UserRef, chatItems: [AChatItem])
+ case chatMsgContent(user: UserRef, msgContent: MsgContent)
case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?)
case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?)
case chatItemUpdated(user: UserRef, chatItem: AChatItem)
@@ -864,6 +875,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case .acceptingContactRequest: "acceptingContactRequest"
case .contactRequestRejected: "contactRequestRejected"
case .newChatItems: "newChatItems"
+ case .chatMsgContent: "chatMsgContent"
case .groupChatItemsDeleted: "groupChatItemsDeleted"
case .forwardPlan: "forwardPlan"
case .chatItemUpdated: "chatItemUpdated"
@@ -898,6 +910,7 @@ enum ChatResponse1: 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 .chatMsgContent(u, mc): return withUser(u, String(describing: mc))
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 .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))")
@@ -932,7 +945,10 @@ enum ChatResponse2: Decodable, ChatAPIResult {
// group responses
case groupCreated(user: UserRef, groupInfo: GroupInfo)
case publicGroupCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay])
+ case publicGroupCreationFailed(user: UserRef, addRelayResults: [AddRelayResult])
case groupRelays(user: UserRef, groupInfo: GroupInfo, groupRelays: [GroupRelay])
+ case groupRelaysAdded(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay])
+ case groupRelaysAddFailed(user: UserRef, addRelayResults: [AddRelayResult])
case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember)
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool)
@@ -984,7 +1000,10 @@ enum ChatResponse2: Decodable, ChatAPIResult {
switch self {
case .groupCreated: "groupCreated"
case .publicGroupCreated: "publicGroupCreated"
+ case .publicGroupCreationFailed: "publicGroupCreationFailed"
case .groupRelays: "groupRelays"
+ case .groupRelaysAdded: "groupRelaysAdded"
+ case .groupRelaysAddFailed: "groupRelaysAddFailed"
case .sentGroupInvitation: "sentGroupInvitation"
case .userAcceptedGroupSent: "userAcceptedGroupSent"
case .userDeletedMembers: "userDeletedMembers"
@@ -1032,7 +1051,10 @@ enum ChatResponse2: Decodable, ChatAPIResult {
switch self {
case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .publicGroupCreated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)")
+ case let .publicGroupCreationFailed(u, addRelayResults): return withUser(u, "addRelayResults: \(addRelayResults)")
case let .groupRelays(u, groupInfo, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupRelays: \(groupRelays)")
+ case let .groupRelaysAdded(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)")
+ case let .groupRelaysAddFailed(u, addRelayResults): return withUser(u, "addRelayResults: \(addRelayResults)")
case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)")
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)")
@@ -1341,6 +1363,11 @@ enum ChatPagination {
}
}
+enum OwnerVerification: Decodable, Hashable {
+ case verified
+ case failed(reason: String)
+}
+
enum ConnectionPlan: Decodable, Hashable {
case invitationLink(invitationLinkPlan: InvitationLinkPlan)
case contactAddress(contactAddressPlan: ContactAddressPlan)
@@ -1349,14 +1376,14 @@ enum ConnectionPlan: Decodable, Hashable {
}
enum InvitationLinkPlan: Decodable, Hashable {
- case ok(contactSLinkData_: ContactShortLinkData?)
+ case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?)
case ownLink
case connecting(contact_: Contact?)
case known(contact: Contact)
}
enum ContactAddressPlan: Decodable, Hashable {
- case ok(contactSLinkData_: ContactShortLinkData?)
+ case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?)
case ownLink
case connectingConfirmReconnect
case connectingProhibit(contact: Contact)
@@ -1371,11 +1398,12 @@ public struct GroupShortLinkInfo: Decodable, Hashable {
}
enum GroupLinkPlan: Decodable, Hashable {
- case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?)
+ case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?)
case ownLink(groupInfo: GroupInfo)
case connectingConfirmReconnect
case connectingProhibit(groupInfo_: GroupInfo?)
case known(groupInfo: GroupInfo)
+ case noRelays(groupSLinkData_: GroupShortLinkData?)
}
struct ChatTagData: Encodable {
@@ -1965,6 +1993,11 @@ struct RelayConnectionResult: Decodable {
var relayError: ChatError?
}
+struct AddRelayResult: Decodable {
+ var relay: UserChatRelay
+ var relayError: ChatError?
+}
+
enum ProtocolTestStep: String, Decodable, Equatable {
case connect
case disconnect
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index 9c23ac6307..a1d28b8e22 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -344,9 +344,12 @@ class ChannelRelaysModel: ObservableObject {
}
func updateRelay(_ groupInfo: GroupInfo, _ relay: GroupRelay) {
- if groupId == groupInfo.groupId,
- let i = groupRelays.firstIndex(where: { $0.groupRelayId == relay.groupRelayId }) {
- groupRelays[i] = relay
+ if groupId == groupInfo.groupId {
+ if let i = groupRelays.firstIndex(where: { $0.groupRelayId == relay.groupRelayId }) {
+ groupRelays[i] = relay
+ } else {
+ groupRelays.append(relay)
+ }
}
}
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index e527df1abd..ea2de31569 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -458,7 +458,7 @@ func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTa
func apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope? = nil) async throws -> [MsgContentTag] {
let r: ChatResponse0 = try await chatSendCmd(.apiGetChatContentTypes(chatId: chatId, scope: scope))
- if case let .chatContentTypes(types) = r { return types }
+ if case let .chatContentTypes(types) = r { return types.filter { if case .unknown = $0 { return false }; return true } }
throw r.unexpected
}
@@ -503,6 +503,12 @@ func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?,
throw r.unexpected
}
+func apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool) async throws -> MsgContent {
+ let r: ChatResponse1 = try await chatSendCmd(.apiShareChatMsgContent(shareChatType: shareChatType, shareChatId: shareChatId, toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup))
+ if case let .chatMsgContent(_, mc) = r { return mc }
+ throw r.unexpected
+}
+
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool = false, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
@@ -1020,12 +1026,12 @@ func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> Pendi
if let r { throw r.unexpected } else { return nil }
}
-func apiConnectPlan(connLink: String, inProgress: BoxedValue) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) {
+func apiConnectPlan(connLink: String, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")
return (nil, nil)
}
- let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink), inProgress: inProgress)
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink, linkOwnerSig: linkOwnerSig), inProgress: inProgress)
if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return ((connLink, connPlan), nil) }
let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil }
return (nil, alert)
@@ -1111,6 +1117,27 @@ private func apiConnectResponseAlert(_ r: APIResult) -> Alert {
}
}
+func connErrorText(_ e: ChatError) -> String {
+ switch e {
+ case .error(.invalidConnReq):
+ NSLocalizedString("Invalid connection link", comment: "conn error description")
+ case .error(.unsupportedConnReq):
+ NSLocalizedString("Unsupported connection link", comment: "conn error description")
+ case .errorAgent(.SMP(_, .AUTH)):
+ NSLocalizedString("Connection error (AUTH)", comment: "conn error description")
+ case let .errorAgent(.SMP(_, .BLOCKED(info))):
+ NSLocalizedString("Connection blocked: \(info.reason.text)", comment: "conn error description")
+ case .errorAgent(.SMP(_, .QUOTA)):
+ NSLocalizedString("The connection reached the limit of undelivered messages", comment: "conn error description")
+ default:
+ if getNetworkErrorAlert(e) != nil {
+ NSLocalizedString("Network error", comment: "conn error description")
+ } else {
+ "\(NSLocalizedString("Error", comment: "conn error description")): \(responseError(e))"
+ }
+ }
+}
+
func contactAlreadyExistsAlert(_ contact: Contact) -> Alert {
mkAlert(
title: "Contact already exists",
@@ -1841,12 +1868,19 @@ func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInf
throw r.unexpected
}
-func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> (GroupInfo, GroupLink, [GroupRelay])? {
+enum PublicGroupCreationResult {
+ case created(GroupInfo, GroupLink, [GroupRelay])
+ case creationFailed([AddRelayResult])
+}
+
+func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> PublicGroupCreationResult? {
let userId = try currentUserId("apiNewPublicGroup")
let r: APIResult? = await chatApiSendCmdWithRetry(.apiNewPublicGroup(userId: userId, incognito: incognito, relayIds: relayIds, groupProfile: groupProfile))
switch r {
case let .result(.publicGroupCreated(_, groupInfo, groupLink, groupRelays)):
- return (groupInfo, groupLink, groupRelays)
+ return .created(groupInfo, groupLink, groupRelays)
+ case let .result(.publicGroupCreationFailed(_, addRelayResults)):
+ return .creationFailed(addRelayResults)
default: if let r { throw r.unexpected } else { return nil }
}
}
@@ -1857,6 +1891,22 @@ func apiGetGroupRelays(_ groupId: Int64) async -> [GroupRelay] {
return []
}
+enum AddGroupRelaysResult {
+ case added(GroupInfo, GroupLink, [GroupRelay])
+ case addFailed([AddRelayResult])
+}
+
+func apiAddGroupRelays(_ groupId: Int64, relayIds: [Int64]) async throws -> AddGroupRelaysResult? {
+ let r: APIResult? = await chatApiSendCmdWithRetry(.apiAddGroupRelays(groupId: groupId, relayIds: relayIds))
+ switch r {
+ case let .result(.groupRelaysAdded(_, groupInfo, groupLink, groupRelays)):
+ return .added(groupInfo, groupLink, groupRelays)
+ case let .result(.groupRelaysAddFailed(_, addRelayResults)):
+ return .addFailed(addRelayResults)
+ default: if let r { throw r.unexpected } else { return nil }
+ }
+}
+
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
if case let .sentGroupInvitation(_, _, _, member) = r { return member }
@@ -2149,7 +2199,7 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws
withAnimation {
let savedOnboardingStage = onboardingStageDefault.get()
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
- ? .step3_ChooseServerOperators
+ ? .step4_NetworkCommitments
: savedOnboardingStage
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
m.setDeliveryReceipts = true
diff --git a/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json b/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json
new file mode 100644
index 0000000000..73c00596a7
--- /dev/null
+++ b/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift
new file mode 100644
index 0000000000..aaaa29929d
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+import SimpleXChat
+
+struct CIChatLinkHeader: View {
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.showTimestamp) var showTimestamp: Bool
+ var chatItem: ChatItem
+ var chatLink: MsgChatLink
+ var ownerSig: LinkOwnerSig?
+ var hasText: Bool
+
+ @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ linkProfileView()
+ .padding(.horizontal, 2)
+ .padding(.top, 8)
+ .padding(.bottom, 6)
+ .overlay(DetermineWidth())
+ Divider()
+ VStack(alignment: .leading, spacing: 2) {
+ if let descr = chatLink.shortDescription {
+ Text(descr)
+ .font(.footnote)
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(2)
+ .padding(.bottom, 2)
+ }
+ Text(chatLink.infoLine(signed: ownerSig != nil))
+ .font(.footnote)
+ .foregroundColor(theme.colors.secondary)
+ .padding(.bottom, 2)
+ let t = Text("Tap to open").foregroundColor(theme.colors.primary).font(.callout)
+ if hasText {
+ t
+ } else {
+ t
+ + Text(verbatim: " ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
+ }
+ }
+ .overlay(DetermineWidth())
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ }
+
+ private func linkProfileView() -> some View {
+ HStack(alignment: .top) {
+ ProfileImage(
+ imageStr: chatLink.image,
+ iconName: chatLink.iconName,
+ size: 44,
+ color: Color(uiColor: .tertiaryLabel)
+ )
+ .padding(.trailing, 4)
+ VStack(alignment: .leading) {
+ Text(chatLink.displayName).font(.headline).lineLimit(2)
+ let fn = chatLink.fullName
+ if fn != "" && fn != chatLink.displayName {
+ Text(fn).font(.subheadline).lineLimit(2)
+ }
+ }
+ .frame(minHeight: 44)
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
index b3fdd3f8e3..4eb187bcac 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift
@@ -14,6 +14,7 @@ import SimpleXChat
struct CILinkView: View {
@EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview
+ let maxWidth: CGFloat
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View {
@@ -21,7 +22,8 @@ struct CILinkView: View {
if let uiImage = imageFromBase64(linkPreview.image) {
Image(uiImage: uiImage)
.resizable()
- .aspectRatio(1 / heightRatio(uiImage.size), contentMode: .fill)
+ .scaledToFill()
+ .frame(width: maxWidth, height: maxWidth * heightRatio(uiImage.size))
.clipped()
.modifier(PrivacyBlur(blurred: $blurred))
.if(!blurred) { v in
@@ -116,7 +118,7 @@ struct LargeLinkPreview_Previews: PreviewProvider {
description: "",
image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"
)
- CILinkView(linkPreview: preview)
+ CILinkView(linkPreview: preview, maxWidth: 360)
.previewLayout(.fixed(width: 360, height: 200))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index ec8bc852c0..d09289c1d5 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -167,8 +167,18 @@ struct FramedItemView: View {
case let .report(text, reason):
ciMsgContentView(chatItem, txtPrefix: reason.attrString)
case let .link(_, preview):
- CILinkView(linkPreview: preview)
+ CILinkView(linkPreview: preview, maxWidth: maxWidth)
ciMsgContentView(chatItem)
+ case let .chat(text, chatLink, ownerSig):
+ let hasText = text != chatLink.connLinkStr
+ CIChatLinkHeader(chatItem: chatItem, chatLink: chatLink, ownerSig: ownerSig, hasText: hasText)
+ .overlay(DetermineWidth())
+ .simultaneousGesture(TapGesture().onEnded {
+ planAndConnect(chatLink.connLinkStr, linkOwnerSig: ownerSig, theme: theme, dismiss: false)
+ })
+ if hasText {
+ ciMsgContentView(chatItem, stripLink: chatLink.connLinkStr)
+ }
case let .unknown(_, text: text):
if chatItem.file == nil {
ciMsgContentView(chatItem)
@@ -244,6 +254,11 @@ struct FramedItemView: View {
ciQuotedMsgView(qi)
.padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
ciQuoteIconView("mic.fill")
+ case let .chat(text, chatLink, _):
+ let prefix = NSAttributedString(string: chatLink.displayName + (text != chatLink.connLinkStr ? " - " : ""))
+ ciQuotedMsgView(qi, stripLink: chatLink.connLinkStr, prefix: prefix)
+ .padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
+ ciQuoteIconView(chatLink.smallIconName)
default:
ciQuotedMsgView(qi)
}
@@ -260,7 +275,7 @@ struct FramedItemView: View {
}
}
- private func ciQuotedMsgView(_ qi: CIQuote) -> some View {
+ private func ciQuotedMsgView(_ qi: CIQuote, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View {
Group {
if let sender = qi.getSender(membership()) {
VStack(alignment: .leading, spacing: 2) {
@@ -268,10 +283,10 @@ struct FramedItemView: View {
.font(.caption)
.foregroundColor(qi.chatDir == .groupSnd ? .accentColor : theme.colors.secondary)
.lineLimit(1)
- ciQuotedMsgTextView(qi, lines: 2)
+ ciQuotedMsgTextView(qi, lines: 2, stripLink: stripLink, prefix: prefix)
}
} else {
- ciQuotedMsgTextView(qi, lines: 3)
+ ciQuotedMsgTextView(qi, lines: 3, stripLink: stripLink, prefix: prefix)
}
}
.fixedSize(horizontal: false, vertical: true)
@@ -280,8 +295,8 @@ struct FramedItemView: View {
}
@inline(__always)
- private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View {
- MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline)
+ private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View {
+ MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline, prefix: prefix, stripLink: stripLink)
.lineLimit(lines)
.padding(.bottom, 6)
}
@@ -303,7 +318,7 @@ struct FramedItemView: View {
}
}
- @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, txtPrefix: NSAttributedString? = nil) -> some View {
+ @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, txtPrefix: NSAttributedString? = nil, stripLink: String? = nil) -> some View {
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
let rtl = isRightToLeft(text)
let ft = text == "" ? [] : ci.formattedText
@@ -316,7 +331,8 @@ struct FramedItemView: View {
mentions: ci.mentions,
userMemberId: chat.chatInfo.groupInfo?.membership.memberId,
rightToLeft: rtl,
- prefix: txtPrefix
+ prefix: txtPrefix,
+ stripLink: stripLink
)
.environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme)))
.multilineTextAlignment(rtl ? .trailing : .leading)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
index fdf3743aac..d831333c20 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
@@ -77,6 +77,25 @@ struct CIMsgError: View {
}
}
+struct RcvMsgErrorItemView: View {
+ @ObservedObject var chat: Chat
+ var rcvMsgError: RcvMsgError
+ var chatItem: ChatItem
+
+ var body: some View {
+ CIMsgError(chat: chat, chatItem: chatItem) {
+ let message: LocalizedStringKey = switch rcvMsgError {
+ case let .dropped(attempts): "The app removed this message after \(attempts) attempts to receive it."
+ case let .parseError(parseError): "\(parseError)"
+ }
+ AlertManager.shared.showAlertMsg(
+ title: "Message error",
+ message: message
+ )
+ }
+ }
+}
+
struct IntegrityErrorItemView_Previews: PreviewProvider {
static var previews: some View {
IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
index 77bd41c5b8..2f4338c0af 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -39,6 +39,7 @@ struct MsgContentView: View {
var userMemberId: String? = nil
var rightToLeft = false
var prefix: NSAttributedString? = nil
+ var stripLink: String? = nil
@State private var showSecrets: Set = []
@State private var typingIdx = 0
@State private var timer: Timer?
@@ -105,7 +106,8 @@ struct MsgContentView: View {
showSecrets: showSecrets,
commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady,
backgroundColor: containerBackground,
- prefix: prefix
+ prefix: prefix,
+ stripLink: stripLink
)
let s = r.string
let t: Text
@@ -289,8 +291,11 @@ func messageText(
showSecrets: Set?,
commands: Bool = false,
backgroundColor: UIColor,
- prefix: NSAttributedString? = nil
+ prefix: NSAttributedString? = nil,
+ stripLink: String? = nil
) -> MsgTextResult {
+ let text = if let stripLink { stripTextLink(text, stripLink) } else { text }
+ let formattedText = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText }
let res = NSMutableAttributedString()
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
let font = UIFont.preferredFont(forTextStyle: textStyle)
@@ -465,6 +470,24 @@ func viaHost(_ smpHosts: [String]) -> String {
"(via \(smpHosts.first ?? "?"))"
}
+func stripTextLink(_ text: String, _ link: String) -> String {
+ text == link
+ ? ""
+ : text.hasSuffix("\n" + link)
+ ? String(text.dropLast(link.count + 1))
+ : text
+}
+
+func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [FormattedText]? {
+ guard var ft, ft.last?.text == link else { return ft }
+ ft.removeLast()
+ if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") {
+ ft[i].text = String(ft[i].text.dropLast())
+ if ft[i].text.isEmpty { ft.removeLast() }
+ }
+ return ft.isEmpty ? nil : ft
+}
+
struct MsgContentView_Previews: PreviewProvider {
static var previews: some View {
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
index dfc620c402..d83a5e8504 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift
@@ -14,9 +14,12 @@ struct ChatItemForwardingView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss
- var chatItems: [ChatItem]
- var fromChatInfo: ChatInfo
- @Binding var composeState: ComposeState
+ var title: LocalizedStringKey = "Forward"
+ var chatItems: [ChatItem] = []
+ var fromChatInfo: ChatInfo? = nil
+ var composeState: Binding? = nil
+ var isProhibited: ((Chat) -> Bool)? = nil
+ var onSelectChat: ((Chat) -> Void)? = nil
@State private var searchText: String = ""
@State private var alert: SomeAlert?
@@ -32,7 +35,7 @@ struct ChatItemForwardingView: View {
}
}
ToolbarItem(placement: .principal) {
- Text("Forward")
+ Text(title)
.bold()
}
}
@@ -71,7 +74,7 @@ struct ChatItemForwardingView: View {
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
- let prohibited = chatItems.map { ci in
+ let prohibited = isProhibited?(chat) ?? chatItems.map { ci in
chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
@@ -88,16 +91,19 @@ struct ChatItemForwardingView: View {
),
id: "forward prohibited by preferences"
)
- } else {
+ } else if let onSelectChat {
+ dismiss()
+ onSelectChat(chat)
+ } else if let fromChatInfo, let composeState {
dismiss()
if chat.id == fromChatInfo.id {
- composeState = ComposeState(
- message: composeState.message,
- preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
+ composeState.wrappedValue = ComposeState(
+ message: composeState.wrappedValue.message,
+ preview: composeState.wrappedValue.linkPreview != nil ? composeState.wrappedValue.preview : .noPreview,
contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo)
)
} else {
- composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo)
+ composeState.wrappedValue = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift
index f72bf083f6..1839651daa 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift
@@ -145,6 +145,7 @@ struct ChatItemContentView: View {
} else {
ZStack {}
}
+ case let .rcvMsgError(rcvMsgError): RcvMsgErrorItemView(chat: chat, rcvMsgError: rcvMsgError, chatItem: chatItem)
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
@@ -171,8 +172,8 @@ struct ChatItemContentView: View {
case .rcvBlocked: deletedItemView()
case let .sndDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo))
case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo))
- case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
- case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
+ case let .sndGroupE2EEInfo(e2eeInfo): CIEventView(eventText: groupE2EEInfoText(e2eeInfo))
+ case let .rcvGroupE2EEInfo(e2eeInfo): CIEventView(eventText: groupE2EEInfoText(e2eeInfo))
case .chatBanner: EmptyView()
case let .invalidJSON(json): CIInvalidJSONView(json: json)
}
@@ -195,7 +196,7 @@ struct ChatItemContentView: View {
}
private func pendingReviewEventItemText() -> Text {
- Text(chatItem.content.text)
+ Text(chatItem.content.text(isChannel: chat.chatInfo.isChannel))
.font(.caption)
.foregroundColor(theme.colors.secondary)
.fontWeight(.bold)
@@ -209,9 +210,9 @@ struct ChatItemContentView: View {
.font(.caption)
.foregroundColor(secondaryColor)
.fontWeight(.light)
- + chatEventText(chatItem, secondaryColor)
+ + chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel)
} else {
- return chatEventText(chatItem, secondaryColor)
+ return chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel)
}
}
@@ -234,7 +235,7 @@ struct ChatItemContentView: View {
return if count <= 1 {
nil
} else if ns.count == 0 {
- Text("\(count) group events")
+ chat.chatInfo.isChannel ? Text("\(count) channel events") : Text("\(count) group events")
} else if count > ns.count {
Text(members) + textSpace + Text("and \(count - ns.count) other events")
} else {
@@ -256,6 +257,12 @@ struct ChatItemContentView: View {
e2eeInfoText("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
}
+ private func groupE2EEInfoText(_ info: E2EEInfo) -> Text {
+ info.public == true
+ ? e2eeInfoText("Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages.")
+ : e2eeInfoNoPQText()
+ }
+
private func e2eeInfoText(_ s: LocalizedStringKey) -> Text {
Text(s)
.font(.caption)
@@ -275,8 +282,8 @@ func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor
chatEventText(Text(eventText) + textSpace + ts, secondaryColor)
}
-func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
- chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor)
+func chatEventText(_ ci: ChatItem, _ secondaryColor: Color, isChannel: Bool = false) -> Text {
+ chatEventText("\(ci.content.text(isChannel: isChannel))", ci.timestampText, secondaryColor)
}
struct ChatItemView_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index 1898fd2851..a141a53b4c 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -141,8 +141,7 @@ struct ChatView: View {
showCommandsMenu: $showCommandsMenu,
keyboardVisible: $keyboardVisible,
keyboardHiddenDate: $keyboardHiddenDate,
- selectedRange: $selectedRange,
- disabledText: chat.chatInfo.userCantSendReason?.composeLabel
+ selectedRange: $selectedRange
)
} else {
SelectedItemsBottomToolbar(
@@ -261,7 +260,9 @@ struct ChatView: View {
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false,
- isChannel: groupInfo.useRelays
+ isChannel: groupInfo.useRelays,
+ groupInfo: groupInfo,
+ composeState: $composeState
)
}
}
@@ -512,6 +513,7 @@ struct ChatView: View {
}
),
scrollToItemId: $scrollToItemId,
+ composeState: $composeState,
onSearch: { focusSearch() },
localAlias: groupInfo.localAlias
)
@@ -743,7 +745,7 @@ struct ChatView: View {
ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays)
}
}
- } else {
+ } else if groupInfo.membership.memberCurrent {
Task {
if let gInfo = await apiGetUpdatedGroupLinkData(groupInfo.groupId) {
await MainActor.run {
@@ -2109,7 +2111,7 @@ struct ChatView: View {
func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
let live = composeState.liveMessage != nil
- let canReply = ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote && selectedChatItems == nil
+ let canReply = ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote && selectedChatItems == nil && chat.chatInfo.sendMsgEnabled
return ZStack(alignment: .trailing) {
Image(systemName: "arrowshape.turn.up.left")
.font(.system(size: 18))
@@ -2277,7 +2279,7 @@ struct ChatView: View {
availableReactions.count > 0 {
reactionsGroup
}
- if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote {
+ if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote && chat.chatInfo.sendMsgEnabled {
replyButton
}
let fileSource = getLoadedFileSource(ci.file)
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift
new file mode 100644
index 0000000000..650ea8a87f
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift
@@ -0,0 +1,42 @@
+import SwiftUI
+import SimpleXChat
+
+struct ComposeChatLinkView: View {
+ @EnvironmentObject var theme: AppTheme
+ var chatLink: MsgChatLink
+ var cancelPreview: () -> Void
+ let cancelEnabled: Bool
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 8) {
+ ProfileImage(
+ imageStr: chatLink.image,
+ iconName: chatLink.iconName,
+ size: 44
+ )
+ .padding(.leading, 12)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(chatLink.displayName)
+ .font(.headline)
+ .lineLimit(1)
+ if let descr = chatLink.shortDescription {
+ Text(descr)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(1)
+ }
+ }
+ .padding(.vertical, 5)
+ Spacer()
+ if cancelEnabled {
+ Button { cancelPreview() } label: {
+ Image(systemName: "multiply")
+ }
+ }
+ }
+ .padding(.vertical, 8)
+ .padding(.trailing, 12)
+ .background(theme.appColors.sentMessage)
+ .frame(maxWidth: .infinity)
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index f37eb614b9..5c57a46129 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
@@ -11,6 +11,7 @@ let MAX_NUMBER_OF_MENTIONS = 3
enum ComposePreview {
case noPreview
case linkPreview(linkPreview: LinkPreview?)
+ case chatLinkPreview(chatLink: MsgChatLink, ownerSig: LinkOwnerSig?)
case mediaPreviews(mediaPreviews: [(String, UploadContent?)])
case voicePreview(recordingFileName: String, duration: Int)
case filePreview(fileName: String, file: URL)
@@ -73,7 +74,11 @@ struct ComposeState {
}
init(editingItem: ChatItem) {
- let text = editingItem.content.text
+ let text = if case let .chat(t, chatLink, _) = editingItem.content.msgContent {
+ stripTextLink(t, chatLink.connLinkStr)
+ } else {
+ editingItem.content.text
+ }
self.message = text
self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text)
self.preview = chatItemPreview(chatItem: editingItem)
@@ -172,6 +177,7 @@ struct ComposeState {
switch preview {
case let .mediaPreviews(media): return !media.isEmpty
case .voicePreview: return voiceMessageRecordingState == .finished
+ case .chatLinkPreview: return true
case .filePreview: return true
default: return !whitespaceOnly || forwarding || liveMessage != nil || submittingValidReport
}
@@ -183,6 +189,7 @@ struct ComposeState {
var linkPreviewAllowed: Bool {
switch preview {
+ case .chatLinkPreview: return false
case .mediaPreviews: return false
case .voicePreview: return false
case .filePreview: return false
@@ -238,6 +245,7 @@ struct ComposeState {
switch preview {
case .noPreview: false
case .linkPreview: false
+ case .chatLinkPreview: false
case let .mediaPreviews(mediaPreviews): !mediaPreviews.isEmpty
case .voicePreview: false
case .filePreview: true
@@ -336,7 +344,6 @@ struct ComposeView: View {
@Binding var keyboardVisible: Bool
@Binding var keyboardHiddenDate: Date
@Binding var selectedRange: NSRange
- var disabledText: LocalizedStringKey? = nil
@State var linkUrl: String? = nil
@State var hasSimplexLink: Bool = false
@@ -363,6 +370,8 @@ struct ComposeView: View {
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
+ @AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS, store: groupDefaults) private var useLinkPreviews = true
+ @AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, store: groupDefaults) private var linkPreviewsShowAlert = true
@State private var updatingCompose = false
@State private var relayListExpanded = false
@StateObject private var channelRelaysModel = ChannelRelaysModel.shared
@@ -382,34 +391,31 @@ struct ComposeView: View {
Divider()
}
+ let ownerState = ownerRelayState
if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) {
if gInfo.membership.memberRole == .owner {
- let relays = channelRelaysModel.groupId == gInfo.groupId
- ? channelRelaysModel.groupRelays : []
- let failedCount = relays.filter { relayMemberConnFailed($0) != nil }.count
- let activeCount = relays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count
- if !relays.isEmpty && activeCount < relays.count {
- ownerChannelRelayBar(relays: relays, activeCount: activeCount, failedCount: failedCount)
+ if let s = ownerState, s.relays.isEmpty || s.activeCount < s.relays.count {
+ ownerChannelRelayBar(relays: s.relays, activeCount: s.activeCount, failedCount: s.failedCount, removedCount: s.removedCount)
}
} else {
let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted()
let relayMembers = chatModel.groupMembers
- .filter { $0.wrapped.memberRole == .relay }
+ .filter { $0.wrapped.memberRole == .relay && ![.memRemoved, .memGroupDeleted].contains($0.wrapped.memberStatus) }
.sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") }
let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress
- let connectedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .ready }.count
- let deletedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .deleted }.count
- let failedCount = relayMembers.filter { $0.wrapped.activeConn?.connFailedErr != nil }.count
- let errorCount = deletedCount + failedCount
- let resolvedCount = connectedCount + deletedCount
+ let removedCount = relayMembers.filter { relayMemberRemoved($0.wrapped.memberStatus) }.count
+ let connectedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connStatus == .ready && $0.wrapped.activeConn?.connFailedErr == nil }.count
+ let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count
+ let resolvedCount = connectedCount + removedCount + failedCount
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
- if total > 0, !showProgress || resolvedCount < total {
+ if total == 0 || removedCount + failedCount > 0 || resolvedCount < total {
subscriberChannelRelayBar(
hostnames: hostnames,
relayMembers: relayMembers,
connectedCount: connectedCount,
- errorCount: errorCount,
+ removedCount: removedCount,
+ failedCount: failedCount,
total: total,
showProgress: showProgress
)
@@ -417,8 +423,9 @@ struct ComposeView: View {
}
}
+ let userCantSendReason = chat.chatInfo.userCantSendReason(allRelaysBroken: ownerState?.noActiveRelays ?? false)
let composeEnabled = (
- chat.chatInfo.sendMsgEnabled ||
+ userCantSendReason == nil ||
(chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) ||
(chat.chatInfo.contact?.nextAcceptContactRequest ?? false)
)
@@ -508,7 +515,7 @@ struct ComposeView: View {
sendMessageView(
disableSendButton,
placeholder: chat.chatInfo.groupInfo.map { gi in
- gi.useRelays && gi.membership.memberRole >= .owner
+ gi.useRelays && gi.membership.memberRole >= .owner && chat.chatInfo.groupChatScope() == nil
? NSLocalizedString("Broadcast", comment: "compose placeholder for channel owner")
: nil
} ?? nil
@@ -521,7 +528,7 @@ struct ComposeView: View {
.disabled(!composeEnabled)
.if(!composeEnabled) { v in
v.onTapGesture {
- if let reason = chat.chatInfo.userCantSendReason {
+ if let reason = userCantSendReason {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
message: reason.alertMessage
@@ -556,7 +563,7 @@ struct ComposeView: View {
} else {
composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
}
- if composeState.linkPreviewAllowed && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) {
+ if composeState.linkPreviewAllowed && useLinkPreviews {
if !msg.isEmpty {
showLinkPreview(parsedMsg)
} else {
@@ -723,35 +730,94 @@ struct ComposeView: View {
}
}
- private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int, failedCount: Int) -> some View {
+ private var ownerRelayState: (relays: [GroupRelay], activeCount: Int, failedCount: Int, removedCount: Int, noActiveRelays: Bool)? {
+ guard let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
+ gInfo.membership.memberRole == .owner,
+ ![.memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus)
+ else { return nil }
+ guard channelRelaysModel.groupId == gInfo.groupId else { return nil }
+ let relays = channelRelaysModel.groupRelays
+ guard !relays.isEmpty else { return ([], 0, 0, 0, true) }
+ let relayMembers = relays.map { relay in
+ (relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped)
+ }
+ let removedCount = relayMembers.filter { (_, m) in relayMemberRemoved(m?.memberStatus) }.count
+ let activeCount = relayMembers.filter { (relay, m) in !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == .rsActive && m?.activeConn?.connFailedErr == nil }.count
+ let failedCount = relayMembers.filter { (_, m) in !relayMemberRemoved(m?.memberStatus) && m?.activeConn?.connFailedErr != nil }.count
+ let noActiveRelays = activeCount == 0 && (failedCount + removedCount) == relays.count
+ return (relays, activeCount, failedCount, removedCount, noActiveRelays)
+ }
+
+ private var disabledText: LocalizedStringKey? {
+ chat.chatInfo.userCantSendReason(allRelaysBroken: ownerRelayState?.noActiveRelays ?? false)?.composeLabel
+ }
+
+ @ViewBuilder private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int, failedCount: Int, removedCount: Int) -> some View {
let total = relays.count
- let sorted = relays.sorted { relayDisplayName($0) < relayDisplayName($1) }
- return VStack(spacing: 0) {
+ let allBroken = activeCount == 0 && (failedCount + removedCount) == total
+ let sorted = relays.map { relay in
+ (relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped)
+ }.sorted { relayDisplayName($0.0) < relayDisplayName($1.0) }
+ VStack(spacing: 0) {
relayBarHeader {
- if activeCount + failedCount < total {
+ if !allBroken && activeCount + failedCount + removedCount < total {
RelayProgressIndicator(active: activeCount, total: total)
}
- if failedCount > 0 {
- Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel relay bar progress with errors"), activeCount, total, failedCount))
+ if total == 0 {
+ Text("No relays")
+ Image(systemName: "exclamationmark.triangle")
+ .foregroundColor(.orange)
+ } else if allBroken {
+ if removedCount == total {
+ Text("All relays removed")
+ } else if failedCount == total {
+ Text("All relays failed")
+ } else {
+ Text("No active relays")
+ }
+ Image(systemName: "exclamationmark.triangle")
+ .foregroundColor(.orange)
+ } else if activeCount + failedCount + removedCount >= total {
+ if failedCount > 0 && removedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d relays not active", comment: "channel relay bar"), failedCount + removedCount))
+ } else if failedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d relays failed", comment: "channel relay bar"), failedCount))
+ } else {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d relays removed", comment: "channel relay bar"), removedCount))
+ }
+ } else if failedCount > 0 && removedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d errors", comment: "channel relay bar"), activeCount, total, failedCount + removedCount))
+ } else if failedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel relay bar"), activeCount, total, failedCount))
+ } else if removedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d removed", comment: "channel relay bar"), activeCount, total, removedCount))
} else {
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total))
}
}
if relayListExpanded {
- ForEach(sorted) { relay in
- let failedErr = relayMemberConnFailed(relay)
- if let err = failedErr {
+ if allBroken {
+ Text("Add relays to restore message delivery.")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ .padding(.leading, 12)
+ .padding(.trailing)
+ .padding(.bottom, 4)
+ }
+ ForEach(sorted, id: \.0.id) { (relay, m) in
+ if let err = m?.activeConn?.connFailedErr {
Button {
showAlert(
NSLocalizedString("Relay connection failed", comment: "alert title"),
message: err
)
} label: {
- ownerRelayDetailRow(relay, connFailed: true)
+ ownerRelayDetailRow(relay, connFailed: true, memberStatus: m?.memberStatus)
}
.buttonStyle(.plain)
} else {
- ownerRelayDetailRow(relay, connFailed: false)
+ ownerRelayDetailRow(relay, connFailed: false, memberStatus: m?.memberStatus)
}
}
}
@@ -760,38 +826,74 @@ struct ComposeView: View {
.animation(nil, value: relayListExpanded)
}
- private func ownerRelayDetailRow(_ relay: GroupRelay, connFailed: Bool) -> some View {
+ private func ownerRelayDetailRow(_ relay: GroupRelay, connFailed: Bool, memberStatus: GroupMemberStatus?) -> some View {
relayBarDetailRow {
Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary)
Spacer()
- relayStatusIndicator(relay.relayStatus, connFailed: connFailed)
+ relayStatusIndicator(relay.relayStatus, connFailed: connFailed, memberStatus: memberStatus)
}
}
- private func subscriberChannelRelayBar(
+ @ViewBuilder private func subscriberChannelRelayBar(
hostnames: [String],
relayMembers: [GMember],
connectedCount: Int,
- errorCount: Int,
+ removedCount: Int,
+ failedCount: Int,
total: Int,
showProgress: Bool
) -> some View {
+ let errorCount = removedCount + failedCount
+ let allBroken = connectedCount == 0 && errorCount == total
VStack(spacing: 0) {
relayBarHeader {
- if showProgress && connectedCount + errorCount < total {
- RelayProgressIndicator(active: connectedCount, total: total)
- }
- if showProgress {
- if errorCount > 0 {
- Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d errors", comment: "channel subscriber relay bar progress with errors"), connectedCount, total, errorCount))
+ if total == 0 {
+ Text("No relays")
+ Image(systemName: "exclamationmark.triangle")
+ .foregroundColor(.orange)
+ } else if allBroken {
+ if removedCount == total {
+ Text("All relays removed")
+ } else if failedCount == total {
+ Text("All relays failed")
+ } else {
+ Text("No active relays")
+ }
+ Image(systemName: "exclamationmark.triangle")
+ .foregroundColor(.orange)
+ } else {
+ if showProgress && connectedCount + errorCount < total {
+ RelayProgressIndicator(active: connectedCount, total: total)
+ }
+ if connectedCount + removedCount + failedCount >= total, removedCount + failedCount > 0 {
+ if failedCount > 0 && removedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d relays not active", comment: "channel subscriber relay bar"), failedCount + removedCount))
+ } else if failedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d relays failed", comment: "channel subscriber relay bar"), failedCount))
+ } else if removedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d relays removed", comment: "channel subscriber relay bar"), removedCount))
+ }
+ } else if failedCount > 0 && removedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d errors", comment: "channel subscriber relay bar"), connectedCount, total, errorCount))
+ } else if failedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d failed", comment: "channel subscriber relay bar"), connectedCount, total, failedCount))
+ } else if removedCount > 0 {
+ Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d removed", comment: "channel subscriber relay bar"), connectedCount, total, removedCount))
} else {
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, total))
}
- } else {
- Text(String.localizedStringWithFormat(NSLocalizedString("%d relays", comment: "channel relay bar"), total))
}
}
if relayListExpanded {
+ if allBroken {
+ Text("Waiting for channel owner to add relays.")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ .padding(.leading, 12)
+ .padding(.trailing)
+ .padding(.bottom, 4)
+ }
if relayMembers.isEmpty {
ForEach(hostnames, id: \.self) { relay in
relayBarDetailRow {
@@ -875,9 +977,9 @@ struct ComposeView: View {
.padding(.vertical, 2)
}
- private func relayMemberConnFailed(_ relay: GroupRelay) -> String? {
- chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?
- .wrapped.activeConn?.connFailedErr
+
+ private func relayMemberRemoved(_ status: GroupMemberStatus?) -> Bool {
+ status.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false
}
private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View {
@@ -920,7 +1022,7 @@ struct ComposeView: View {
private func sendMessageView(_ disableSendButton: Bool, placeholder: String? = nil, sendToConnect: (() -> Void)? = nil) -> some View {
ZStack(alignment: .leading) {
SendMessageView(
- placeholder: placeholder,
+ placeholder: disabledText != nil ? nil : placeholder,
composeState: $composeState,
selectedRange: $selectedRange,
sendMessage: { ttl in
@@ -1216,6 +1318,15 @@ struct ComposeView: View {
cancelEnabled: !composeState.inProgress
)
Divider()
+ case let .chatLinkPreview(chatLink, _):
+ ComposeChatLinkView(
+ chatLink: chatLink,
+ cancelPreview: {
+ composeState = composeState.copy(preview: .noPreview)
+ },
+ cancelEnabled: !composeState.inProgress
+ )
+ Divider()
case let .mediaPreviews(mediaPreviews: media):
ComposeImageView(
images: media.map { (img, _) in img },
@@ -1374,6 +1485,10 @@ struct ComposeView: View {
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
+ case let .chatLinkPreview(chatLink, ownerSig):
+ let linkStr = chatLink.connLinkStr
+ let text = msgText.isEmpty ? linkStr : msgText + "\n" + linkStr
+ sent = await send(.chat(text: text, chatLink: chatLink, ownerSig: ownerSig), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
case let .mediaPreviews(media):
// TODO: CHECK THIS
let last = media.count - 1
@@ -1476,9 +1591,9 @@ struct ComposeView: View {
return .file(msgText)
case .report(_, let reason):
return .report(text: msgText, reason: reason)
- // TODO [short links] update chat link
- case let .chat(_, chatLink):
- return .chat(text: msgText, chatLink: chatLink)
+ case let .chat(_, chatLink, ownerSig):
+ let text = msgText.isEmpty ? chatLink.connLinkStr : msgText + "\n" + chatLink.connLinkStr
+ return .chat(text: text, chatLink: chatLink, ownerSig: ownerSig)
case .unknown(let type, _):
return .unknown(type: type, text: msgText)
}
@@ -1552,7 +1667,7 @@ struct ComposeView: View {
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
scope: chat.chatInfo.groupChatScope(),
- sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false,
+ sendAsGroup: chat.chatInfo.sendAsGroup,
live: live,
ttl: ttl,
composedMessages: msgs
@@ -1773,21 +1888,55 @@ struct ComposeView: View {
// Spec: spec/client/compose.md#loadLinkPreview
private func loadLinkPreview(_ urlStr: String) {
if pendingLinkUrl == urlStr, let url = URL(string: urlStr) {
- composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
- getLinkPreview(url: url) { linkPreview in
- if let linkPreview, pendingLinkUrl == urlStr {
- privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5
- composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
- } else {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
- composeState = composeState.copy(preview: .noPreview)
+ if linkPreviewsShowAlert {
+ showLinkPreviewsConfirmAlert { enable in
+ if let enable {
+ linkPreviewsShowAlert = false
+ useLinkPreviews = enable
+ UserDefaults.standard.set(enable, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
+ if enable {
+ fetchLinkPreview(url, urlStr: urlStr)
+ } else {
+ pendingLinkUrl = nil
+ composeState = composeState.copy(preview: .noPreview)
+ }
+ } else {
+ cancelLinkPreview()
}
}
- pendingLinkUrl = nil
+ return
}
+ fetchLinkPreview(url, urlStr: urlStr)
}
}
+ private func fetchLinkPreview(_ url: URL, urlStr: String) {
+ composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
+ getLinkPreview(url: url) { linkPreview in
+ if let linkPreview, pendingLinkUrl == urlStr {
+ composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
+ } else {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ composeState = composeState.copy(preview: .noPreview)
+ }
+ }
+ pendingLinkUrl = nil
+ }
+ }
+
+ private func showLinkPreviewsConfirmAlert(onChoice: @escaping (Bool?) -> Void) {
+ showAlert(
+ NSLocalizedString("Enable link previews?", comment: "alert title"),
+ message: NSLocalizedString("Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later.", comment: "alert message"),
+ actions: {
+ [
+ UIAlertAction(title: NSLocalizedString("Disable", comment: "alert button"), style: .destructive) { _ in onChoice(false) },
+ UIAlertAction(title: NSLocalizedString("Enable", comment: "alert button"), style: .default) { _ in onChoice(true) }
+ ]
+ }
+ )
+ }
+
private func resetLinkPreview() {
linkUrl = nil
prevLinkUrl = nil
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
index 845442c75f..1f328b2061 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
@@ -71,7 +71,7 @@ struct ContextItemView: View {
}
private func contextMsgPreview(_ contextItem: ChatItem) -> some View {
- let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background))
+ let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr)
let t = attachment() + Text(AttributedString(r.string))
return t.if(r.hasSecrets, transform: hiddenSecretsView)
@@ -83,6 +83,9 @@ struct ContextItemView: View {
case .file: return isFileLoaded ? image("doc.fill") : Text("")
case .image: return image("photo")
case .voice: return isFileLoaded ? image("play.fill") : Text("")
+ case let .chat(_, chatLink, _):
+ let hasText = contextItem.text != chatLink.connLinkStr
+ return image(chatLink.smallIconName) + Text(chatLink.displayName) + Text(verbatim: hasText ? " - " : "")
default: return Text("")
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift
new file mode 100644
index 0000000000..82b89beaa5
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift
@@ -0,0 +1,161 @@
+//
+// AddGroupRelayView.swift
+// SimpleX (iOS)
+//
+// Created by simplex on 29.04.2026.
+// Copyright © 2026 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct AddGroupRelayView: View {
+ var groupInfo: GroupInfo
+ var existingRelayIds: Set
+ var onRelayAdded: () -> Void
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.dismiss) var dismiss
+ @State private var availableRelays: [(relayId: Int64, relay: UserChatRelay, operatorName: String?)] = []
+ @State private var selectedRelayIds: Set = []
+ @State private var isLoading = true
+ @State private var isAdding = false
+
+ var body: some View {
+ NavigationView {
+ List {
+ if isLoading {
+ Section {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ }
+ } else if availableRelays.isEmpty {
+ Section {
+ Text("No available relays")
+ .foregroundColor(theme.colors.secondary)
+ }
+ } else {
+ Section {
+ ForEach(availableRelays, id: \.relayId) { item in
+ relayCheckRow(item.relayId, item.relay, operatorName: item.operatorName)
+ }
+ }
+ }
+ }
+ .modifier(ThemedBackground(grouped: true))
+ .navigationTitle("Add relays")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Add") { addSelectedRelays() }
+ .disabled(selectedRelayIds.isEmpty || isAdding)
+ }
+ }
+ }
+ .task { await loadAvailableRelays() }
+ }
+
+ private func relayCheckRow(_ relayId: Int64, _ relay: UserChatRelay, operatorName: String?) -> some View {
+ let selected = selectedRelayIds.contains(relayId)
+ return Button {
+ if selected {
+ selectedRelayIds.remove(relayId)
+ } else {
+ selectedRelayIds.insert(relayId)
+ }
+ } label: {
+ HStack {
+ VStack(alignment: .leading) {
+ Text(chatRelayDisplayName(relay))
+ .foregroundColor(theme.colors.onBackground)
+ .lineLimit(1)
+ if let opName = operatorName {
+ Text(opName)
+ .font(.caption)
+ .foregroundColor(theme.colors.secondary)
+ .lineLimit(1)
+ }
+ }
+ Spacer()
+ Image(systemName: selected ? "checkmark.circle.fill" : "circle")
+ .foregroundColor(selected ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme))
+ }
+ }
+ }
+
+ private func loadAvailableRelays() async {
+ do {
+ let servers = try await getUserServers()
+ var relays: [(relayId: Int64, relay: UserChatRelay, operatorName: String?)] = []
+ for op in servers {
+ if let oper = op.operator, oper.enabled != true { continue }
+ let opName: String? = op.operator?.operatorTag != nil ? op.operator?.tradeName : nil
+ for relay in op.chatRelays {
+ if relay.enabled && !relay.deleted,
+ let relayId = relay.chatRelayId,
+ !existingRelayIds.contains(relayId) {
+ relays.append((relayId, relay, opName))
+ }
+ }
+ }
+ await MainActor.run {
+ availableRelays = relays
+ isLoading = false
+ }
+ } catch {
+ logger.error("loadAvailableRelays error: \(responseError(error))")
+ await MainActor.run {
+ isLoading = false
+ }
+ }
+ }
+
+ private func addSelectedRelays() {
+ let relayIds = Array(selectedRelayIds)
+ guard !relayIds.isEmpty else { return }
+ isAdding = true
+ Task {
+ do {
+ guard let result = try await apiAddGroupRelays(groupInfo.groupId, relayIds: relayIds) else {
+ await MainActor.run { isAdding = false }
+ return
+ }
+ await MainActor.run {
+ isAdding = false
+ switch result {
+ case let .added(gInfo, _, relays):
+ ChannelRelaysModel.shared.set(groupId: gInfo.groupId, groupRelays: relays)
+ onRelayAdded()
+ dismiss()
+ case let .addFailed(results):
+ let successIds = Set(results.filter { $0.relayError == nil }.compactMap { $0.relay.chatRelayId })
+ if !successIds.isEmpty {
+ selectedRelayIds.subtract(successIds)
+ availableRelays.removeAll { successIds.contains($0.relayId) }
+ onRelayAdded()
+ }
+ let errorLines = results.filter { $0.relayError != nil }
+ .map { "\(chatRelayDisplayName($0.relay)): \($0.relayError.map { connErrorText($0) } ?? "")" }
+ let successNames = results.filter { $0.relayError == nil }
+ .map { chatRelayDisplayName($0.relay) }
+ var msg = errorLines.joined(separator: "\n")
+ if !successNames.isEmpty {
+ msg += "\n" + String.localizedStringWithFormat(NSLocalizedString("Relays added: %@.", comment: "alert message"), successNames.joined(separator: ", "))
+ }
+ showAlert(
+ NSLocalizedString("Error adding relays", comment: "alert title"),
+ message: msg
+ )
+ }
+ }
+ } catch {
+ await MainActor.run {
+ isAdding = false
+ showAlert(NSLocalizedString("Error adding relays", comment: "alert title"), message: responseError(error))
+ }
+ }
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift
index 1a4e384e24..6600cec47b 100644
--- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift
@@ -14,24 +14,49 @@ struct ChannelRelaysView: View {
var groupInfo: GroupInfo
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
- @State private var groupRelays: [GroupRelay] = []
+ @ObservedObject private var channelRelaysModel = ChannelRelaysModel.shared
+ @State private var showAddRelay = false
+
+ private var groupRelays: [GroupRelay] {
+ channelRelaysModel.groupId == groupInfo.groupId ? channelRelaysModel.groupRelays : []
+ }
var body: some View {
List {
relaysList()
+ // TODO [relays] re-enable when relay management ships
+ // if groupInfo.isOwner {
+ // Section {
+ // Button {
+ // showAddRelay = true
+ // } label: {
+ // Label("Add relay", systemImage: "plus")
+ // }
+ // }
+ // }
}
+ // TODO [relays] re-enable when relay management ships
+ // .sheet(isPresented: $showAddRelay) {
+ // let existingRelayIds = Set(groupRelays.filter { $0.relayStatus != .rsInactive }.compactMap { $0.userChatRelay.chatRelayId })
+ // AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) {
+ // Task { await chatModel.loadGroupMembers(groupInfo) }
+ // }
+ // }
.onAppear {
Task {
await chatModel.loadGroupMembers(groupInfo)
if groupInfo.isOwner {
- groupRelays = await apiGetGroupRelays(groupInfo.groupId)
+ let relays = await apiGetGroupRelays(groupInfo.groupId)
+ await MainActor.run {
+ ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays)
+ }
}
}
}
}
@ViewBuilder private func relaysList() -> some View {
- let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay }
+ let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberStatus != .memRemoved && $0.wrapped.memberStatus != .memGroupDeleted }
if relayMembers.isEmpty {
Section {
Text("No chat relays")
@@ -40,7 +65,7 @@ struct ChannelRelaysView: View {
} else {
Section {
ForEach(relayMembers) { member in
- NavigationLink {
+ let link = NavigationLink {
GroupMemberInfoView(
groupInfo: groupInfo,
chat: chat,
@@ -55,6 +80,20 @@ struct ChannelRelaysView: View {
: subscriberRelayStatusText(member.wrapped)
relayMemberRow(member.wrapped, statusText: statusText)
}
+ // TODO [relays] re-enable when relay management ships
+ // if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) {
+ // link.swipeActions(edge: .trailing) {
+ // Button {
+ // showRemoveMemberAlert(groupInfo, member.wrapped)
+ // } label: {
+ // Label("Remove relay", systemImage: "trash")
+ // }
+ // .tint(.red)
+ // }
+ // } else {
+ // link
+ // }
+ link
}
} footer: {
Text("Chat relays forward messages to channel subscribers.")
@@ -73,7 +112,9 @@ struct ChannelRelaysView: View {
}
private func ownerRelayStatusText(_ member: GroupMember) -> LocalizedStringKey {
- if case .failed = member.activeConn?.connStatus {
+ if [.memLeft, .memRemoved, .memGroupDeleted].contains(member.memberStatus) {
+ relayConnStatus(member).text
+ } else if case .failed = member.activeConn?.connStatus {
"failed"
} else if member.activeConn?.connDisabled ?? false {
"disabled"
@@ -104,11 +145,16 @@ struct ChannelRelaysView: View {
}
func relayConnStatus(_ member: GroupMember) -> (text: LocalizedStringKey, color: Color) {
- switch member.activeConn?.connStatus {
- case .ready: ("connected", .green)
- case .deleted: ("deleted", .red)
- case .failed: ("failed", .red)
- default: ("connecting", .yellow)
+ switch member.memberStatus {
+ case .memLeft: ("removed by operator", .red)
+ case .memRemoved, .memGroupDeleted: (member.memberStatus.text, .red)
+ default:
+ switch member.activeConn?.connStatus {
+ case .ready: ("connected", .green)
+ case .deleted: ("deleted", .red)
+ case .failed: ("failed", .red)
+ default: ("connecting", .yellow)
+ }
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index c02f4dae36..eee9500b3b 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -20,8 +20,10 @@ struct GroupChatInfoView: View {
@ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo
@Binding var scrollToItemId: ChatItem.ID?
+ @Binding var composeState: ComposeState
var onSearch: () -> Void
@State var localAlias: String
+ @State private var showSharePicker = false
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: GroupLink?
@@ -101,6 +103,10 @@ struct GroupChatInfoView: View {
}
}
+ let showUserSupportChat = groupInfo.membership.memberActive
+ && ((groupInfo.fullGroupPreferences.support.on && groupInfo.membership.memberRole < .moderator)
+ || groupInfo.membership.supportChat != nil)
+
if groupInfo.useRelays {
Section {
// TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership)
@@ -113,10 +119,21 @@ struct GroupChatInfoView: View {
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
+ Button {
+ showSharePicker = true
+ } label: {
+ Label("Share via chat", systemImage: "arrowshape.turn.up.forward")
+ }
}
if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) {
channelMembersButton()
}
+ if groupInfo.membership.memberRole >= .moderator {
+ memberSupportButton()
+ }
+ if showUserSupportChat {
+ UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
+ }
} footer: {
if !groupInfo.isOwner && groupInfo.groupProfile.publicGroup?.groupLink != nil {
Text("You can share a link or a QR code - anybody will be able to join the channel.")
@@ -134,8 +151,7 @@ struct GroupChatInfoView: View {
if groupInfo.canModerate {
GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
}
- if groupInfo.membership.memberActive
- && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) {
+ if showUserSupportChat {
UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
}
} header: {
@@ -150,19 +166,17 @@ struct GroupChatInfoView: View {
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
addOrEditWelcomeMessage()
}
- if !groupInfo.useRelays {
- GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
- }
+ GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
} footer: {
- if !groupInfo.useRelays {
- let label: LocalizedStringKey = (
- groupInfo.businessChat == nil
- ? "Only group owners can change group preferences."
- : "Only chat owners can change preferences."
- )
- Text(label)
- .foregroundColor(theme.colors.secondary)
- }
+ let label: LocalizedStringKey = (
+ groupInfo.useRelays
+ ? "Only channel owners can change channel preferences."
+ : groupInfo.businessChat == nil
+ ? "Only group owners can change group preferences."
+ : "Only chat owners can change preferences."
+ )
+ Text(label)
+ .foregroundColor(theme.colors.secondary)
}
Section {
@@ -248,6 +262,9 @@ struct GroupChatInfoView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .sheet(isPresented: $showSharePicker) {
+ shareChannelPicker(groupInfo: groupInfo, composeState: $composeState)
+ }
.alert(item: $alert) { alertItem in
switch(alertItem) {
case .deleteGroupAlert: return deleteGroupAlert()
@@ -638,7 +655,9 @@ struct GroupChatInfoView: View {
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false,
- isChannel: groupInfo.useRelays
+ isChannel: groupInfo.useRelays,
+ groupInfo: groupInfo,
+ composeState: $composeState
)
.navigationBarTitle(groupInfo.useRelays ? "Channel link" : "Group link")
.modifier(ThemedBackground(grouped: true))
@@ -905,26 +924,54 @@ struct GroupChatInfoView: View {
}
func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) {
- showAlert(
- groupInfo.useRelays
- ? NSLocalizedString("Remove subscriber?", comment: "alert title")
- : NSLocalizedString("Remove member?", comment: "alert title"),
- message:
- groupInfo.useRelays
- ? NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message")
- : groupInfo.businessChat == nil
- ? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
- : NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
- actions: {[
- UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
- removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
- },
- UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
- removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
- },
- cancelAlertAction
- ]}
- )
+ if mem.memberRole == .relay {
+ let isLastActive = groupInfo.useRelays && mem.memberCurrent && {
+ let activeRelays = ChatModel.shared.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberCurrent }
+ return activeRelays.count <= 1
+ }()
+ showAlert(
+ NSLocalizedString("Remove relay?", comment: "alert title"),
+ message: isLastActive
+ ? NSLocalizedString("This is the last active relay. Removing it will prevent message delivery to subscribers.", comment: "alert message")
+ : NSLocalizedString("Relay will be removed from channel - this cannot be undone!", comment: "alert message"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
+ removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
+ },
+ cancelAlertAction
+ ]}
+ )
+ } else if groupInfo.useRelays {
+ showAlert(
+ NSLocalizedString("Remove subscriber?", comment: "alert title"),
+ message: NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
+ removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
+ },
+ UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
+ removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
+ },
+ cancelAlertAction
+ ]}
+ )
+ } else {
+ showAlert(
+ NSLocalizedString("Remove member?", comment: "alert title"),
+ message: groupInfo.businessChat == nil
+ ? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
+ : NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
+ removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
+ },
+ UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
+ removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
+ },
+ cancelAlertAction
+ ]}
+ )
+ }
}
func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) {
@@ -976,7 +1023,9 @@ struct GroupPreferencesButton: View {
var creatingGroup: Bool = false
private var label: LocalizedStringKey {
- groupInfo.businessChat == nil ? "Group preferences" : "Chat preferences"
+ groupInfo.useRelays ? "Channel preferences"
+ : groupInfo.businessChat == nil ? "Group preferences"
+ : "Chat preferences"
}
var body: some View {
@@ -993,7 +1042,9 @@ struct GroupPreferencesButton: View {
.navigationBarTitleDisplayMode(.large)
.onDisappear {
let saveText = NSLocalizedString(
- creatingGroup ? "Save" : "Save and notify group members",
+ creatingGroup ? "Save"
+ : groupInfo.useRelays ? "Save and notify subscribers"
+ : "Save and notify group members",
comment: "alert button"
)
@@ -1048,12 +1099,66 @@ func largeGroupReceiptsDisabledAlert() -> Alert {
)
}
+@ViewBuilder
+func shareChannelPicker(groupInfo: GroupInfo, composeState: Binding? = nil) -> some View {
+ let v = ChatItemForwardingView(
+ title: "Share channel",
+ isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) },
+ onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) }
+ )
+ if #available(iOS 16.0, *) {
+ v.presentationDetents([.fraction(0.8)])
+ } else {
+ v
+ }
+}
+
+func shareChatLink(_ destChat: Chat, sourceGroupInfo: GroupInfo, composeState: Binding? = nil) {
+ let sendAsGroup = if let gInfo = destChat.chatInfo.groupInfo { gInfo.useRelays && gInfo.membership.memberRole >= .owner } else { false }
+ Task {
+ do {
+ let mc = try await apiShareChatMsgContent(
+ shareChatType: .group, shareChatId: Int64(sourceGroupInfo.groupId),
+ toChatType: destChat.chatInfo.chatType, toChatId: destChat.chatInfo.apiId,
+ toScope: destChat.chatInfo.groupChatScope(), sendAsGroup: sendAsGroup
+ )
+ if case let .chat(_, chatLink, ownerSig) = mc {
+ await MainActor.run {
+ dismissAllSheets {
+ let cs = ComposeState(preview: .chatLinkPreview(chatLink: chatLink, ownerSig: ownerSig))
+ if let composeState {
+ composeState.wrappedValue = cs
+ } else {
+ ChatModel.shared.draft = cs
+ ChatModel.shared.draftChatId = destChat.id
+ }
+ if destChat.id != ChatModel.shared.chatId {
+ ItemsModel.shared.loadOpenChat(destChat.id)
+ }
+ }
+ }
+ } else {
+ logger.error("shareChatLink: unexpected MsgContent: \(String(describing: mc))")
+ await MainActor.run {
+ showAlert(NSLocalizedString("Error sharing channel", comment: "alert title"), message: String(describing: mc))
+ }
+ }
+ } catch {
+ logger.error("shareChatLink error: \(error.localizedDescription)")
+ await MainActor.run {
+ showAlert(NSLocalizedString("Error sharing channel", comment: "alert title"), message: error.localizedDescription)
+ }
+ }
+ }
+}
+
struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData),
scrollToItemId: Binding.constant(nil),
+ composeState: Binding.constant(ComposeState()),
onSearch: {},
localAlias: ""
)
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
index 56ee370402..22253c4808 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift
@@ -18,7 +18,10 @@ struct GroupLinkView: View {
var showTitle: Bool = false
var creatingGroup: Bool = false
var isChannel: Bool = false
+ var groupInfo: GroupInfo? = nil
+ var composeState: Binding? = nil
var linkCreatedCb: (() -> Void)? = nil
+ @State private var showSharePicker = false
@State private var showShortLink = true
@State private var creatingLink = false
@State private var alert: GroupLinkAlert?
@@ -104,6 +107,11 @@ struct GroupLinkView: View {
} label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
+ if groupInfo?.groupProfile.publicGroup != nil {
+ Button { showSharePicker = true } label: {
+ Label("Share via chat", systemImage: "arrowshape.turn.up.forward")
+ }
+ }
if !creatingGroup && !isChannel {
Button(role: .destructive) { alert = .deleteLink } label: {
@@ -160,6 +168,11 @@ struct GroupLinkView: View {
}
}
.modifier(ThemedBackground(grouped: true))
+ .sheet(isPresented: $showSharePicker) {
+ if let gInfo = groupInfo {
+ shareChannelPicker(groupInfo: gInfo, composeState: composeState)
+ }
+ }
}
private func createGroupLink() {
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
index af7054db01..883a768d97 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -121,13 +121,15 @@ struct GroupMemberInfoView: View {
}
if connectionLoaded {
+ let showMemberSupportChat = !openedFromSupportChat
+ && groupInfo.membership.memberRole >= .moderator
+ && member.memberRole != .relay
+ && ((groupInfo.fullGroupPreferences.support.on && member.memberRole < .moderator)
+ || member.supportChat != nil)
if member.memberActive {
Section {
- if !openedFromSupportChat
- && groupInfo.membership.memberRole >= .moderator
- && member.memberRole != .relay
- && (member.memberRole < .moderator || member.supportChat != nil) {
+ if showMemberSupportChat {
MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId)
}
if let code = connectionCode,
@@ -142,6 +144,10 @@ struct GroupMemberInfoView: View {
// synchronizeConnectionButtonForce()
// }
}
+ } else if groupInfo.useRelays && member.memberCurrent && showMemberSupportChat {
+ Section {
+ MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId)
+ }
}
if let contactLink = member.contactLink {
@@ -635,13 +641,12 @@ struct GroupMemberInfoView: View {
blockForAllButton(mem)
}
}
- // TODO [relays] removing relay should also remove its link from group link data;
- // TODO - removing last relay should be prohibited or show warning
+ // TODO [relays] re-enable when relay management ships
if canRemove && mem.memberRole != .relay {
- if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft {
- deleteMemberMessagesButton(mem)
- } else {
+ if mem.memberStatus != .memRemoved && (mem.memberStatus != .memLeft || mem.memberRole == .relay) {
removeMemberButton(mem)
+ } else if mem.memberRole != .relay {
+ deleteMemberMessagesButton(mem)
}
}
}
@@ -699,7 +704,10 @@ struct GroupMemberInfoView: View {
Button(role: .destructive) {
showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss)
} label: {
- Label(groupInfo.useRelays ? "Remove subscriber" : "Remove member", systemImage: "trash")
+ let text = mem.memberRole == .relay ? "Remove relay"
+ : groupInfo.useRelays ? "Remove subscriber"
+ : "Remove member"
+ Label(text, systemImage: "trash")
.foregroundColor(.red)
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
index 55b1dc6d2e..cc2feef706 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift
@@ -27,26 +27,50 @@ struct GroupPreferencesView: View {
@State private var showSaveDialogue = false
var body: some View {
- let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members"
+ let saveText: LocalizedStringKey = creatingGroup ? "Save" : groupInfo.useRelays ? "Save and notify subscribers" : "Save and notify group members"
VStack {
List {
- Section {
- MemberAdmissionButton(
- groupInfo: $groupInfo,
- admission: groupInfo.groupProfile.memberAdmission_,
- currentAdmission: groupInfo.groupProfile.memberAdmission_,
- creatingGroup: creatingGroup
- )
+ if !groupInfo.useRelays {
+ Section {
+ MemberAdmissionButton(
+ groupInfo: $groupInfo,
+ admission: groupInfo.groupProfile.memberAdmission_,
+ currentAdmission: groupInfo.groupProfile.memberAdmission_,
+ creatingGroup: creatingGroup
+ )
+ }
+ featureSection(.timedMessages, $preferences.timedMessages.enable)
+ featureSection(.fullDelete, $preferences.fullDelete.enable)
+ featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role)
+ featureSection(.reactions, $preferences.reactions.enable)
+ featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
+ featureSection(.files, $preferences.files.enable, $preferences.files.role)
+ featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
+ featureSection(.reports, $preferences.reports.enable, disabled: true) // enable reports in 7.0 once directory support added
+ featureSection(.history, $preferences.history.enable)
+ featureSection(.support, $preferences.support.enable, disabled: true)
+ } else {
+ featureSection(.timedMessages, $preferences.timedMessages.enable)
+ featureSection(.fullDelete, $preferences.fullDelete.enable)
+ featureSection(.reactions, $preferences.reactions.enable)
+ featureSection(.history, $preferences.history.enable)
+ let supportNotice = NSLocalizedString("Chats with admins in public channels have no E2E encryption - use only with trusted chat relays.", comment: "alert message")
+ featureSection(.support, $preferences.support.enable, notice: supportNotice)
+ .onChange(of: preferences.support.enable) { enable in
+ if enable == .on {
+ showAlert(
+ NSLocalizedString("Enable chats with admins?", comment: "alert title"),
+ message: supportNotice,
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Enable", comment: "alert button"), style: .destructive) { _ in },
+ UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel) { _ in
+ preferences.support.enable = .off
+ }
+ ]}
+ )
+ }
+ }
}
- featureSection(.timedMessages, $preferences.timedMessages.enable)
- featureSection(.fullDelete, $preferences.fullDelete.enable)
- featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role)
- featureSection(.reactions, $preferences.reactions.enable)
- featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
- featureSection(.files, $preferences.files.enable, $preferences.files.role)
- featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
- featureSection(.reports, $preferences.reports.enable)
- featureSection(.history, $preferences.history.enable)
if groupInfo.isOwner {
Section {
@@ -85,7 +109,7 @@ struct GroupPreferencesView: View {
}
}
- private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil) -> some View {
+ private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil, disabled: Bool = false, notice: String? = nil) -> some View {
Section {
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
@@ -96,9 +120,9 @@ struct GroupPreferencesView: View {
set: { on, _ in enableFeature.wrappedValue = on ? .on : .off }
)
settingsRow(icon, color: color) {
- Toggle(feature.text, isOn: enable)
+ Toggle(feature.text(isChannel: groupInfo.isChannel), isOn: enable)
}
- .disabled(feature == .reports) // remove in 6.4
+ .disabled(disabled)
if timedOn {
DropdownCustomTimePicker(
selection: $preferences.timedMessages.ttl,
@@ -119,7 +143,7 @@ struct GroupPreferencesView: View {
}
} else {
settingsRow(icon, color: color) {
- infoRow(Text(feature.text), enableFeature.wrappedValue.text)
+ infoRow(Text(feature.text(isChannel: groupInfo.isChannel)), enableFeature.wrappedValue.text)
}
if timedOn {
infoRow("Delete after", timeText(preferences.timedMessages.ttl))
@@ -137,8 +161,11 @@ struct GroupPreferencesView: View {
}
}
} footer: {
- Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner))
- .foregroundColor(theme.colors.secondary)
+ VStack(alignment: .leading) {
+ Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner, isChannel: groupInfo.isChannel))
+ if let notice { Text(notice) }
+ }
+ .foregroundColor(theme.colors.secondary)
}
.onChange(of: enableFeature.wrappedValue) { enabled in
if case .off = enabled {
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
index 69587c0152..126fcc57b3 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
@@ -37,12 +37,12 @@ struct GroupProfileView: View {
var body: some View {
List {
- EditProfileImage(profileImage: $groupProfile.image, showChooseSource: $showChooseSource)
+ EditProfileImage(profileImage: $groupProfile.image, iconName: groupInfo.chatIconName, showChooseSource: $showChooseSource)
.if(!focusDisplayName) { $0.padding(.top) }
Section {
HStack {
- TextField("Group display name", text: $groupProfile.displayName)
+ TextField(groupInfo.useRelays ? "Channel display name" : "Group display name", text: $groupProfile.displayName)
.focused($focusDisplayName)
if !validNewProfileName {
Button {
@@ -54,7 +54,7 @@ struct GroupProfileView: View {
}
let fullName = groupInfo.groupProfile.fullName
if fullName != "" && fullName != groupProfile.displayName {
- TextField("Group full name (optional)", text: $groupProfile.fullName)
+ TextField(groupInfo.useRelays ? "Channel full name (optional)" : "Group full name (optional)", text: $groupProfile.fullName)
}
HStack {
TextField("Short description", text: $shortDescr)
@@ -67,7 +67,7 @@ struct GroupProfileView: View {
}
}
} footer: {
- Text("Group profile is stored on members' devices, not on the servers.")
+ Text(groupInfo.useRelays ? "Channel profile is stored on subscribers' devices and on the chat relays." : "Group profile is stored on members' devices, not on the servers.")
}
Section {
@@ -80,11 +80,11 @@ struct GroupProfileView: View {
currentProfileHash == groupProfile.hashValue &&
(groupInfo.groupProfile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces)
)
- Button("Save group profile", action: saveProfile)
+ Button(groupInfo.useRelays ? "Save channel profile" : "Save group profile", action: saveProfile)
.disabled(!canUpdateProfile)
}
}
- .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
+ .confirmationDialog(groupInfo.useRelays ? "Channel image" : "Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
showTakePhoto = true
}
@@ -130,9 +130,15 @@ struct GroupProfileView: View {
.onDisappear {
if canUpdateProfile {
showAlert(
- title: NSLocalizedString("Save group profile?", comment: "alert title"),
- message: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"),
- buttonTitle: NSLocalizedString("Save (and notify members)", comment: "alert button"),
+ title: groupInfo.useRelays
+ ? NSLocalizedString("Save channel profile?", comment: "alert title")
+ : NSLocalizedString("Save group profile?", comment: "alert title"),
+ message: groupInfo.useRelays
+ ? NSLocalizedString("Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers.", comment: "alert message")
+ : NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"),
+ buttonTitle: groupInfo.useRelays
+ ? NSLocalizedString("Save (and notify subscribers)", comment: "alert button")
+ : NSLocalizedString("Save (and notify members)", comment: "alert button"),
buttonAction: saveProfile,
cancelButton: true
)
@@ -142,14 +148,14 @@ struct GroupProfileView: View {
switch a {
case let .saveError(err):
return Alert(
- title: Text("Error saving group profile"),
+ title: Text(groupInfo.useRelays ? "Error saving channel profile" : "Error saving group profile"),
message: Text(err)
)
case let .invalidName(name):
return createInvalidNameAlert(name, $groupProfile.displayName)
}
}
- .navigationBarTitle("Group profile")
+ .navigationBarTitle(groupInfo.useRelays ? "Channel profile" : "Group profile")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(focusDisplayName ? .inline : .large)
}
diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
index 3dc27c08f6..880933985c 100644
--- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift
@@ -45,7 +45,7 @@ struct MemberSupportView: View {
: membersWithChats.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
if membersWithChats.isEmpty {
- Text("No chats with members")
+ Text(groupInfo.fullGroupPreferences.support.on ? "No chats with members" : "Chats with members are disabled")
.foregroundColor(.secondary)
} else {
List {
diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift
index 3050b0d4cd..dc4971aafa 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift
@@ -38,8 +38,9 @@ enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
case favorites = 1
case contacts = 2
case groups = 3
- case business = 4
- case notes = 5
+ case channels = 4
+ case business = 5
+ case notes = 6
var id: Int { rawValue }
@@ -293,36 +294,40 @@ struct ChatListView: View {
@ToolbarContentBuilder var topToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem }
- ToolbarItem(placement: .principal) { SubsStatusIndicator() }
+ ToolbarItem(placement: .principal) { if !shouldShowOnboarding { SubsStatusIndicator() } }
ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem }
}
-
+
@ToolbarContentBuilder var bottomToolbar: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItem(placement: .bottomBar) {
HStack {
leadingToolbarItem.padding(.bottom, padding)
Spacer()
- SubsStatusIndicator().padding(.bottom, padding)
- Spacer()
+ if !shouldShowOnboarding {
+ SubsStatusIndicator().padding(.bottom, padding)
+ Spacer()
+ }
trailingToolbarItem.padding(.bottom, padding)
}
.contentShape(Rectangle())
.onTapGesture { scrollToSearchBar = true }
}
}
-
+
@ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) {
leadingToolbarItem.padding(.bottom, padding)
Spacer()
- SubsStatusIndicator().padding(.bottom, padding)
- Spacer()
+ if !shouldShowOnboarding {
+ SubsStatusIndicator().padding(.bottom, padding)
+ Spacer()
+ }
trailingToolbarItem.padding(.bottom, padding)
}
}
-
+
@ViewBuilder var leadingToolbarItem: some View {
let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) {
@@ -348,7 +353,34 @@ struct ChatListView: View {
}
}
- private var chatList: some View {
+ private var shouldShowOnboarding: Bool {
+ !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations
+ }
+
+ private var hasConversations: Bool {
+ chatModel.chats.contains { chat in
+ switch chat.chatInfo {
+ case .local: return false
+ case let .direct(contact): return !contact.chatDeleted && !contact.isContactCard
+ case .group: return true
+ case .contactRequest: return false
+ case .contactConnection: return false
+ case .invalidJSON: return false
+ }
+ }
+ }
+
+ @ViewBuilder private var chatList: some View {
+ if shouldShowOnboarding {
+ ConnectOnboardingView()
+ .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
+ .modifier(ThemedBackground())
+ } else {
+ chatListContent
+ }
+ }
+
+ private var chatListContent: some View {
let cs = filteredChats()
return ZStack {
ScrollViewReader { scrollProxy in
@@ -369,6 +401,13 @@ struct ChatListView: View {
.padding(.top, oneHandUI ? 8 : 0)
.id("searchBar")
}
+ if !oneHandUICardShown {
+ OneHandUICard()
+ .padding(.vertical, 6)
+ .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
+ .listRowSeparator(.hidden)
+ .listRowBackground(Color.clear)
+ }
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat, parentSheet: $sheet)
@@ -388,15 +427,8 @@ struct ChatListView: View {
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
- if !oneHandUICardShown {
- OneHandUICard()
- .padding(.vertical, 6)
- .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
- .listRowSeparator(.hidden)
- .listRowBackground(Color.clear)
- }
- if !addressCreationCardShown {
- AddressCreationCard()
+ if !addressCreationCardShown && hasConversations {
+ ConnectBannerCard()
.padding(.vertical, 6)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
@@ -807,11 +839,11 @@ struct TagsView: View {
nil
}
let active = tag == selectedPresetTag
- let (icon, text) = presetTagLabel(tag: tag, active: active)
+ let (icon, menuIcon, text) = presetTagLabel(tag: tag, active: active)
let color: Color = active ? .accentColor : .secondary
HStack(spacing: 4) {
- Image(systemName: icon)
+ Image(systemName: menuIcon ?? icon)
.foregroundColor(color)
ZStack {
Text(text).fontWeight(.semibold).foregroundColor(.clear)
@@ -854,9 +886,9 @@ struct TagsView: View {
Button {
setActiveFilter(filter: .presetTag(tag))
} label: {
- let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag)
+ let (icon, _, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag)
HStack {
- Image(systemName: systemName)
+ Image(systemName: icon)
Text(text)
}
}
@@ -864,8 +896,8 @@ struct TagsView: View {
}
} label: {
if let tag = selectedPresetTag, tag.сollapse {
- let (systemName, _) = presetTagLabel(tag: tag, active: true)
- Image(systemName: systemName)
+ let (icon, menuIcon, _) = presetTagLabel(tag: tag, active: true)
+ Image(systemName: menuIcon ?? icon)
.foregroundColor(.accentColor)
} else {
Image(systemName: "list.bullet")
@@ -875,14 +907,15 @@ struct TagsView: View {
.frame(minWidth: 28)
}
- private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) {
+ private func presetTagLabel(tag: PresetTag, active: Bool) -> (item: String, menu: String?, label: LocalizedStringKey) {
switch tag {
- case .groupReports: (active ? "flag.fill" : "flag", "Reports")
- case .favorites: (active ? "star.fill" : "star", "Favorites")
- case .contacts: (active ? "person.fill" : "person", "Contacts")
- case .groups: (active ? "person.2.fill" : "person.2", "Groups")
- case .business: (active ? "briefcase.fill" : "briefcase", "Businesses")
- case .notes: (active ? "folder.fill" : "folder", "Notes")
+ case .groupReports: (item: active ? "flag.fill" : "flag", menu: nil, label: "Reports")
+ case .favorites: (item: active ? "star.fill" : "star", menu: nil, label: "Favorites")
+ case .contacts: (item: active ? "person.fill" : "person", menu: nil, label: "Contacts")
+ case .groups: (item: active ? "person.2.fill" : "person.2", menu: nil, label: "Groups")
+ case .channels: (item: active ? "antenna.radiowaves.left.and.right.circle.fill" : "antenna.radiowaves.left.and.right", menu: "antenna.radiowaves.left.and.right", label: "Channels")
+ case .business: (item: active ? "briefcase.fill" : "briefcase", menu: nil, label: "Businesses")
+ case .notes: (item: active ? "folder.fill" : "folder", menu: nil, label: "Notes")
}
}
@@ -924,7 +957,12 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: C
}
case .groups:
switch chatInfo {
- case let .group(groupInfo, _): groupInfo.businessChat == nil
+ case let .group(groupInfo, _): groupInfo.businessChat == nil && !groupInfo.isChannel
+ default: false
+ }
+ case .channels:
+ switch chatInfo {
+ case let .group(groupInfo, _): groupInfo.isChannel
default: false
}
case .business:
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 112e4099c0..243d804685 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -296,11 +296,23 @@ struct ChatPreviewView: View {
}
func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
- let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
- let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
+ let (itemText, itemFormattedText) = chatItemPreviewText(cItem)
let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix())
return (Text(AttributedString(r.string)), r.hasSecrets)
+ func chatItemPreviewText(_ ci: ChatItem) -> (String, [FormattedText]?) {
+ if ci.meta.itemDeleted != nil {
+ return (markedDeletedText(), nil)
+ }
+ if case let .chat(_, chatLink, _) = ci.content.msgContent {
+ let descr = if let descr = chatLink.shortDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
+ descr != "" { "\n" + descr } else { "" }
+ let text = chatLink.displayName + descr
+ return (text, nil)
+ }
+ return (ci.text(isChannel: chat.chatInfo.isChannel), ci.formattedText)
+ }
+
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type
func markedDeletedText() -> String {
@@ -426,6 +438,18 @@ struct ChatPreviewView: View {
smallContentPreviewFile(size: dynamicMediaSize) {
CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize)
}
+ case let .chat(_, chatLink, ownerSig):
+ smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) {
+ ProfileImage(
+ imageStr: chatLink.image,
+ iconName: chatLink.iconName,
+ size: dynamicMediaSize,
+ color: Color(uiColor: .tertiaryLabel)
+ )
+ .onTapGesture {
+ planAndConnect(chatLink.connLinkStr, linkOwnerSig: ownerSig, theme: theme, dismiss: false)
+ }
+ }
default: EmptyView()
}
}
@@ -499,12 +523,12 @@ func flagIcon(size: CGFloat, color: Color) -> some View {
.foregroundColor(color)
}
-func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
+func smallContentPreview(size: CGFloat, borderColor: Color = .secondary, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.cornerRadius(8)
.overlay(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
- .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true))
+ .strokeBorder(borderColor, lineWidth: 0.3, antialiased: true))
.padding(.vertical, size / 6)
.padding(.leading, 3)
.offset(x: 6)
diff --git a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift
index 059f24cc82..132a19d7e7 100644
--- a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift
+++ b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift
@@ -11,27 +11,46 @@ import SimpleXChat
struct OneHandUICard: View {
@EnvironmentObject var theme: AppTheme
- @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
+ @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@State private var showOneHandUIAlert = false
var body: some View {
- ZStack(alignment: .topTrailing) {
- VStack(alignment: .leading, spacing: 8) {
- Text("Toggle chat list:").font(.title3)
- Toggle("Reachable chat toolbar", isOn: $oneHandUI)
+ HStack(spacing: 2) {
+ segment(
+ icon: "platter.filled.bottom.and.arrow.down.iphone",
+ text: "Bottom bar",
+ isSelected: oneHandUI
+ ) {
+ withAnimation { oneHandUI = true }
}
- Image(systemName: "multiply")
- .foregroundColor(theme.colors.secondary)
- .onTapGesture {
- showOneHandUIAlert = true
+ .background { if oneHandUI { Color(uiColor: .systemGray5) } }
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ ZStack(alignment: .trailing) {
+ segment(
+ icon: "platter.filled.top.and.arrow.up.iphone",
+ text: "Top bar",
+ isSelected: !oneHandUI
+ ) {
+ withAnimation { oneHandUI = false }
}
+ Image(systemName: "multiply")
+ .foregroundColor(theme.colors.secondary)
+ .frame(width: 12, height: 12)
+ .padding(.vertical, 4)
+ .padding(.trailing, 16)
+ .padding(.leading, 4)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ showOneHandUIAlert = true
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background { if !oneHandUI { Color(uiColor: .systemGray5) } }
+ .background(ToolbarMaterial.material(toolbarMaterial))
}
- .padding()
- .background(theme.appColors.sentMessage)
- .cornerRadius(12)
- .frame(height: dynamicSize(userFont).rowHeight)
+ .clipShape(Capsule())
.alert(isPresented: $showOneHandUIAlert) {
Alert(
title: Text("Reachable chat toolbar"),
@@ -44,6 +63,22 @@ struct OneHandUICard: View {
)
}
}
+
+ private func segment(icon: String, text: LocalizedStringKey, isSelected: Bool, action: @escaping () -> Void) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.body)
+ .foregroundColor(isSelected ? theme.colors.secondary : theme.colors.primary)
+ Text(text)
+ .font(.subheadline)
+ .foregroundColor(isSelected ? theme.colors.secondary : theme.colors.onBackground)
+ }
+ .padding(.leading, 16)
+ .padding(.vertical, 4)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .onTapGesture { action() }
+ }
}
#Preview {
diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift
index 76bdc898d5..56e343588d 100644
--- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift
+++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift
@@ -110,8 +110,8 @@ struct MigrateToAppGroupView: View {
do {
resetChatCtrl()
try initializeChat(start: true)
- onboardingStageDefault.set(.step4_SetNotificationsMode)
- chatModel.onboardingStage = .step4_SetNotificationsMode
+ onboardingStageDefault.set(.step4_NetworkCommitments)
+ chatModel.onboardingStage = .step4_NetworkCommitments
setV3DBMigration(.ready)
} catch let error {
dbContainerGroupDefault.set(.documents)
diff --git a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
index 980308f13c..0491b38575 100644
--- a/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
+++ b/apps/ios/Shared/Views/Helpers/ChatItemClipShape.swift
@@ -37,6 +37,7 @@ struct ChatItemClipped: ViewModifier {
.rcvMsgContent,
.rcvDecryptionError,
.rcvIntegrityError,
+ .rcvMsgError,
.invalidJSON:
let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty {
false
diff --git a/apps/ios/Shared/Views/Helpers/DetermineWidth.swift b/apps/ios/Shared/Views/Helpers/DetermineWidth.swift
index b05ab17089..54e9fe0e80 100644
--- a/apps/ios/Shared/Views/Helpers/DetermineWidth.swift
+++ b/apps/ios/Shared/Views/Helpers/DetermineWidth.swift
@@ -21,6 +21,19 @@ struct DetermineWidth: View {
}
}
+struct DetermineHeight: View {
+ typealias Key = MaximumHeightPreferenceKey
+ var body: some View {
+ GeometryReader { proxy in
+ Color.clear
+ .preference(
+ key: MaximumHeightPreferenceKey.self,
+ value: proxy.size.height
+ )
+ }
+ }
+}
+
struct DetermineWidthImageVideoItem: View {
typealias Key = MaximumWidthImageVideoPreferenceKey
var body: some View {
@@ -41,6 +54,13 @@ struct MaximumWidthPreferenceKey: PreferenceKey {
}
}
+struct MaximumHeightPreferenceKey: PreferenceKey {
+ static var defaultValue: CGFloat = 0
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value = max(value, nextValue())
+ }
+}
+
struct MaximumWidthImageVideoPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
index 2ef928c7c5..9f2fc833ba 100644
--- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
@@ -86,6 +86,40 @@ func showSheet(
}
}
+func openExternalLink(_ url: URL) {
+ let s = url.absoluteString
+ if s.starts(with: "https://simplex.chat/contact#") || (s.starts(with: "https://smp") && s.contains(".simplex.im/a#")) {
+ ChatModel.shared.appOpenUrl = url
+ } else {
+ showAlert(
+ title: NSLocalizedString("Open external link?", comment: "alert title"),
+ message: s,
+ buttonTitle: NSLocalizedString("Open", comment: "alert button"),
+ buttonAction: { UIApplication.shared.open(url) },
+ cancelButton: true
+ )
+ }
+}
+
+struct ExternalLink: View {
+ let destination: URL
+ let label: Label
+
+ init(destination: URL, @ViewBuilder label: () -> Label) {
+ self.destination = destination
+ self.label = label()
+ }
+
+ init(_ titleKey: LocalizedStringKey, destination: URL) where Label == Text {
+ self.destination = destination
+ self.label = Text(titleKey)
+ }
+
+ var body: some View {
+ Button { openExternalLink(destination) } label: { label }
+ }
+}
+
let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default)
let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)
@@ -101,25 +135,28 @@ class OpenChatAlertViewController: UIViewController {
private let profileFullName: String
private let profileImage: UIView
private let subtitle: String?
+ private let information: String?
private let cancelTitle: String
- private let confirmTitle: String
+ private let confirmTitle: String?
private let onCancel: () -> Void
- private let onConfirm: () -> Void
+ private let onConfirm: (() -> Void)?
init(
profileName: String,
profileFullName: String,
profileImage: UIView,
subtitle: String? = nil,
+ information: String? = nil,
cancelTitle: String = "Cancel",
- confirmTitle: String = "Open",
- onCancel: @escaping () -> Void,
- onConfirm: @escaping () -> Void
+ confirmTitle: String? = "Open",
+ onCancel: @escaping () -> Void = {},
+ onConfirm: (() -> Void)? = nil
) {
self.profileName = profileName
self.profileFullName = profileFullName
self.profileImage = profileImage
self.subtitle = subtitle
+ self.information = information
self.cancelTitle = cancelTitle
self.confirmTitle = confirmTitle
self.onCancel = onCancel
@@ -180,12 +217,24 @@ class OpenChatAlertViewController: UIViewController {
subtitleLabel.text = subtitle
subtitleLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
subtitleLabel.textColor = .secondaryLabel
- subtitleLabel.numberOfLines = 1
+ subtitleLabel.numberOfLines = 3
subtitleLabel.textAlignment = .center
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
profileViews.append(subtitleLabel)
}
+ // Information label (e.g. owner verification)
+ if let information {
+ let infoLabel = UILabel()
+ infoLabel.text = information
+ infoLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
+ infoLabel.textColor = .label
+ infoLabel.numberOfLines = 3
+ infoLabel.textAlignment = .center
+ infoLabel.translatesAutoresizingMaskIntoConstraints = false
+ profileViews.append(infoLabel)
+ }
+
// Horizontal stack for image + name
let stack = UIStackView(arrangedSubviews: profileViews)
stack.axis = .vertical
@@ -211,20 +260,54 @@ class OpenChatAlertViewController: UIViewController {
cancelButton.titleLabel?.font = UIFont(descriptor: bodyDescr.withSymbolicTraits(.traitBold) ?? bodyDescr, size: 0)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
- let confirmButton = UIButton(type: .system)
- confirmButton.setTitle(confirmTitle, for: .normal)
- confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
- confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
+ let buttonStack: UIStackView
+ var buttonDividerConstraints: [NSLayoutConstraint] = []
- let verticalButtons = cancelButton.intrinsicContentSize.width + 20 >= alertWidth / 2 || confirmButton.intrinsicContentSize.width + 20 >= alertWidth / 2
+ if let confirmTitle {
+ let confirmButton = UIButton(type: .system)
+ confirmButton.setTitle(confirmTitle, for: .normal)
+ confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
+ confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
- // Button stack with equal width buttons
- let buttonStack = UIStackView(arrangedSubviews: verticalButtons ? [confirmButton, cancelButton] : [cancelButton, confirmButton])
- buttonStack.axis = verticalButtons ? .vertical : .horizontal
- buttonStack.distribution = .fillEqually
- buttonStack.spacing = 0 // no spacing, use divider instead
- buttonStack.translatesAutoresizingMaskIntoConstraints = false
- buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight * (verticalButtons ? 2 : 1)).isActive = true
+ let verticalButtons = cancelButton.intrinsicContentSize.width + 20 >= alertWidth / 2 || confirmButton.intrinsicContentSize.width + 20 >= alertWidth / 2
+
+ // Button stack with equal width buttons
+ buttonStack = UIStackView(arrangedSubviews: verticalButtons ? [confirmButton, cancelButton] : [cancelButton, confirmButton])
+ buttonStack.axis = verticalButtons ? .vertical : .horizontal
+ buttonStack.distribution = .fillEqually
+ buttonStack.spacing = 0 // no spacing, use divider instead
+ buttonStack.translatesAutoresizingMaskIntoConstraints = false
+ buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight * (verticalButtons ? 2 : 1)).isActive = true
+
+ // Add divider between buttons
+ let buttonDivider = UIView()
+ buttonDivider.backgroundColor = UIColor.separator
+ buttonDivider.translatesAutoresizingMaskIntoConstraints = false
+ buttonStack.addSubview(buttonDivider)
+
+ buttonDividerConstraints = if verticalButtons {
+ [
+ buttonDivider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
+ buttonDivider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
+ buttonDivider.centerYAnchor.constraint(equalTo: buttonStack.centerYAnchor),
+ buttonDivider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
+ ]
+ } else {
+ [
+ buttonDivider.topAnchor.constraint(equalTo: buttonStack.topAnchor),
+ buttonDivider.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+ buttonDivider.centerXAnchor.constraint(equalTo: buttonStack.centerXAnchor),
+ buttonDivider.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
+ ]
+ }
+ } else {
+ // Single button
+ buttonStack = UIStackView(arrangedSubviews: [cancelButton])
+ buttonStack.axis = .horizontal
+ buttonStack.distribution = .fillEqually
+ buttonStack.translatesAutoresizingMaskIntoConstraints = false
+ buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight).isActive = true
+ }
// Vertical stack containing hStack and buttonStack
let vStack = UIStackView(arrangedSubviews: [topRowContainer, buttonStack])
@@ -241,29 +324,6 @@ class OpenChatAlertViewController: UIViewController {
horizontalDivider.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(horizontalDivider)
- // Add divider between buttons
- let buttonDivider = UIView()
- buttonDivider.backgroundColor = UIColor.separator
- buttonDivider.translatesAutoresizingMaskIntoConstraints = false
- buttonStack.addSubview(buttonDivider)
-
- // Constraints
- let buttonDividerConstraints = if verticalButtons {
- [
- buttonDivider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
- buttonDivider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
- buttonDivider.centerYAnchor.constraint(equalTo: buttonStack.centerYAnchor),
- buttonDivider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
- ]
- } else {
- [
- buttonDivider.topAnchor.constraint(equalTo: buttonStack.topAnchor),
- buttonDivider.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
- buttonDivider.centerXAnchor.constraint(equalTo: buttonStack.centerXAnchor),
- buttonDivider.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
- ]
- }
-
NSLayoutConstraint.activate([
// Container view centering and fixed width
containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
@@ -295,7 +355,7 @@ class OpenChatAlertViewController: UIViewController {
@objc private func confirmTapped() {
dismiss(animated: true) {
- self.onConfirm()
+ self.onConfirm?()
}
}
}
@@ -307,10 +367,11 @@ func showOpenChatAlert(
profileImage: Content,
theme: AppTheme,
subtitle: String? = nil,
+ information: String? = nil,
cancelTitle: String = "Cancel",
- confirmTitle: String = "Open",
+ confirmTitle: String? = "Open",
onCancel: @escaping () -> Void = {},
- onConfirm: @escaping () -> Void
+ onConfirm: (() -> Void)? = nil
) {
let themedView = profileImage.environmentObject(theme)
let hostingController = UIHostingController(rootView: themedView)
@@ -323,6 +384,7 @@ func showOpenChatAlert(
profileFullName: profileFullName,
profileImage: hostedView,
subtitle: subtitle,
+ information: information,
cancelTitle: cancelTitle,
confirmTitle: confirmTitle,
onCancel: onCancel,
diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift
index 098cccef1b..32d6e7fe2c 100644
--- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift
+++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct AddChannelView: View {
+ @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@StateObject private var channelRelaysModel = ChannelRelaysModel.shared
@@ -45,28 +46,39 @@ struct AddChannelView: View {
private func profileStepView() -> some View {
List {
Group {
- ZStack(alignment: .center) {
- ZStack(alignment: .topTrailing) {
- ProfileImage(imageStr: profile.image, size: 128)
- if profile.image != nil {
- Button {
- profile.image = nil
- } label: {
- Image(systemName: "multiply")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 12)
+ HStack(spacing: 0) {
+ Spacer(minLength: 0)
+ ZStack(alignment: .center) {
+ ZStack(alignment: .topTrailing) {
+ ProfileImage(imageStr: profile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128)
+ if profile.image != nil {
+ Button {
+ profile.image = nil
+ } label: {
+ Image(systemName: "multiply")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 12)
+ }
}
}
+ editImageButton { showChooseSource = true }
+ .buttonStyle(BorderlessButtonStyle())
}
- editImageButton { showChooseSource = true }
- .buttonStyle(BorderlessButtonStyle())
+ .padding(.horizontal, 10) // Offsets transparent space built into 3D asset
+ #if SIMPLEX_ASSETS
+ Spacer(minLength: 0)
+ Image(colorScheme == .light ? "create-channel" : "create-channel-light")
+ .resizable()
+ .scaledToFit()
+ .frame(height: 140)
+ #endif
+ Spacer(minLength: 0)
}
- .frame(maxWidth: .infinity, alignment: .center)
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
- .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
+ .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0))
Section {
channelNameTextField()
@@ -161,7 +173,10 @@ struct AddChannelView: View {
private func createChannel() {
focusDisplayName = false
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
- profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
+ profile.groupPreferences = GroupPreferences(
+ history: GroupPreference(enable: .on),
+ support: GroupPreference(enable: .off)
+ )
creationInProgress = true
Task {
do {
@@ -174,20 +189,32 @@ struct AddChannelView: View {
}
return
}
- guard let (gInfo, gLink, gRelays) = try await apiNewPublicGroup(
+ guard let result = try await apiNewPublicGroup(
incognito: false, relayIds: relayIds, groupProfile: profile
) else {
await MainActor.run { creationInProgress = false }
return
}
- await MainActor.run {
- m.updateGroup(gInfo)
- m.creatingChannelId = gInfo.id
- groupInfo = gInfo
- groupLink = gLink
- groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) }
- channelRelaysModel.set(groupId: gInfo.groupId, groupRelays: gRelays)
- creationInProgress = false
+ switch result {
+ case let .created(gInfo, gLink, gRelays):
+ await MainActor.run {
+ m.updateGroup(gInfo)
+ m.creatingChannelId = gInfo.id
+ groupInfo = gInfo
+ groupLink = gLink
+ groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) }
+ channelRelaysModel.set(groupId: gInfo.groupId, groupRelays: gRelays)
+ creationInProgress = false
+ }
+ case let .creationFailed(relayResults):
+ await MainActor.run {
+ creationInProgress = false
+ showAlert(
+ NSLocalizedString("Error creating channel", comment: "alert title"),
+ message: NSLocalizedString("Relay results:", comment: "alert message") + "\n" +
+ relayResults.map { "\(chatRelayDisplayName($0.relay)): \($0.relayError.map { connErrorText($0) } ?? "ok")" }.joined(separator: "\n")
+ )
+ }
}
} catch {
await MainActor.run {
@@ -210,6 +237,7 @@ struct AddChannelView: View {
var operatorGroups: [[UserChatRelay]] = []
var customRelays: [UserChatRelay] = []
for op in servers {
+ guard op.operator?.enabled ?? true else { continue }
let relays = op.chatRelays.filter { $0.enabled && !$0.deleted && $0.chatRelayId != nil }
guard !relays.isEmpty else { continue }
if op.operator != nil {
@@ -244,6 +272,7 @@ struct AddChannelView: View {
private func checkHasRelays() async -> Bool {
guard let servers = try? await getUserServers() else { return false }
return servers.contains { op in
+ (op.operator?.enabled ?? true) &&
op.chatRelays.contains { $0.enabled && !$0.deleted && $0.chatRelayId != nil }
}
}
@@ -256,7 +285,7 @@ struct AddChannelView: View {
let total = groupRelays.count
return List {
Group {
- ProfileImage(imageStr: gInfo.groupProfile.image, size: 128)
+ ProfileImage(imageStr: gInfo.groupProfile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128)
.frame(maxWidth: .infinity, alignment: .center)
Text(gInfo.groupProfile.displayName)
@@ -309,24 +338,27 @@ struct AddChannelView: View {
.compactSectionSpacing()
Section {
- Button("Channel link") {
+ Button("Cancel and delete channel", role: .destructive) {
+ showCancelChannelAlert(gInfo)
+ }
+ Button("Continue") {
if activeCount >= total {
showLinkStep = true
} else if activeCount > 0 {
let actions: [UIAlertAction] = if activeCount + failedCount < total {
[
- UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true },
+ UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in showLinkStep = true },
UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in }
]
} else {
[
- UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true },
+ UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in showLinkStep = true },
cancelAlertAction
]
}
showAlert(
NSLocalizedString("Not all relays connected", comment: "alert title"),
- message: String.localizedStringWithFormat(NSLocalizedString("Channel will start working with %d of %d relays. Proceed?", comment: "alert message"), activeCount, total),
+ message: String.localizedStringWithFormat(NSLocalizedString("Channel will start working with %d of %d relays. Continue?", comment: "alert message"), activeCount, total),
actions: { actions }
)
}
@@ -336,9 +368,9 @@ struct AddChannelView: View {
}
.navigationTitle("Creating channel")
.navigationBarBackButtonHidden(true)
- .toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
- Button("Cancel") { cancelChannelCreation(gInfo) }
+ .onDisappear {
+ if !showLinkStep && m.creatingChannelId == gInfo.id {
+ showCancelChannelAlert(gInfo)
}
}
.onChange(of: channelRelaysModel.groupRelays) { relays in
@@ -373,7 +405,8 @@ struct AddChannelView: View {
groupLinkMemberRole: Binding.constant(.observer), // TODO [relays] starting role should be communicated in protocol from owner to relays
showTitle: false,
creatingGroup: true,
- isChannel: true
+ isChannel: true,
+ groupInfo: gInfo
) {
m.creatingChannelId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
@@ -399,6 +432,24 @@ struct AddChannelView: View {
}
}
+ private func showCancelChannelAlert(_ gInfo: GroupInfo) {
+ let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count
+ let total = groupRelays.count
+ showAlert(
+ NSLocalizedString("Cancel creating channel?", comment: "alert title"),
+ message: String.localizedStringWithFormat(
+ NSLocalizedString("Your new channel %@ is connected to %d of %d relays.\nIf you cancel, the channel will be deleted - you can create it again.", comment: "alert message"),
+ gInfo.groupProfile.displayName, activeCount, total
+ ),
+ actions: {[
+ UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in },
+ UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert action"), style: .destructive) { _ in
+ cancelChannelCreation(gInfo)
+ }
+ ]}
+ )
+ }
+
// MARK: - Helpers
private func showInvalidChannelNameAlert() {
@@ -428,9 +479,15 @@ func relayDisplayName(_ relay: GroupRelay) -> String {
return "relay \(relay.groupRelayId)"
}
-func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false) -> some View {
- let color: Color = connFailed ? .red : (status == .rsActive ? .green : .yellow)
- let text: LocalizedStringKey = connFailed ? "failed" : status.text
+func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
+ if !relay.displayName.isEmpty { return relay.displayName }
+ return relay.address
+}
+
+func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View {
+ let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false
+ let color: Color = connFailed || removed ? .red : (status == .rsActive ? .green : .yellow)
+ let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : removed ? "removed" : status.text
return HStack(spacing: 4) {
Circle()
.fill(color)
diff --git a/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift b/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift
index 3a64a955c5..6add190b88 100644
--- a/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift
+++ b/apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift
@@ -26,7 +26,7 @@ struct AddContactLearnMore: View {
VStack(alignment: .leading, spacing: 18) {
Text("To connect, your contact can scan QR code or use the link in the app.")
Text("If you can't meet in person, show QR code in a video call, or share the link.")
- Text("Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).")
+ ExternalLink("Read more in User Guide.", destination: URL(string: "https://simplex.chat/docs/guide/readme.html#connect-to-friends")!)
}
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.clear)
diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift
index c74e016974..47afee5f06 100644
--- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift
+++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct AddGroupView: View {
+ @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@@ -66,29 +67,40 @@ struct AddGroupView: View {
func createGroupView() -> some View {
List {
Group {
- ZStack(alignment: .center) {
- ZStack(alignment: .topTrailing) {
- ProfileImage(imageStr: profile.image, size: 128)
- if profile.image != nil {
- Button {
- profile.image = nil
- } label: {
- Image(systemName: "multiply")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 12)
+ HStack(spacing: 0) {
+ Spacer(minLength: 0)
+ ZStack(alignment: .center) {
+ ZStack(alignment: .topTrailing) {
+ ProfileImage(imageStr: profile.image, iconName: "person.2.circle.fill", size: 128)
+ if profile.image != nil {
+ Button {
+ profile.image = nil
+ } label: {
+ Image(systemName: "multiply")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 12)
+ }
}
}
- }
- editImageButton { showChooseSource = true }
- .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable
+ editImageButton { showChooseSource = true }
+ .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable
+ }
+ .padding(.horizontal, 10) // Offsets transparent space built into 3D asset
+ #if SIMPLEX_ASSETS
+ Spacer(minLength: 0)
+ Image(colorScheme == .light ? "create-group" : "create-group-light")
+ .resizable()
+ .scaledToFit()
+ .frame(height: 140)
+ #endif
+ Spacer(minLength: 0)
}
- .frame(maxWidth: .infinity, alignment: .center)
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
- .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
+ .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0))
Section {
groupNameTextField()
diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
index 8e62923f3f..177f8761f4 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift
@@ -55,7 +55,7 @@ struct NewChatSheet: View {
let showArchive = chatModel.chats.contains { $0.chatInfo.contact?.chatDeleted == true }
let v = NavigationView {
viewBody(showArchive)
- .navigationTitle("New message")
+ .navigationTitle("New chat")
.navigationBarTitleDisplayMode(.large)
.navigationBarHidden(searchMode)
.modifier(ThemedBackground(grouped: true))
@@ -99,9 +99,8 @@ struct NewChatSheet: View {
Section {
NavigationLink(isActive: $isAddContactActive) {
NewChatView(selection: .invite)
- .navigationTitle("New chat")
.modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
+ .navigationBarTitleDisplayMode(.inline)
} label: {
navigateOnTap(Label("Create 1-time link", systemImage: "link.badge.plus")) {
isAddContactActive = true
@@ -109,9 +108,8 @@ struct NewChatSheet: View {
}
NavigationLink(isActive: $isScanPasteLinkActive) {
NewChatView(selection: .connect, showQRCodeScanner: true)
- .navigationTitle("New chat")
.modifier(ThemedBackground(grouped: true))
- .navigationBarTitleDisplayMode(.large)
+ .navigationBarTitleDisplayMode(.inline)
} label: {
navigateOnTap(Label("Scan / Paste link", systemImage: "qrcode")) {
isScanPasteLinkActive = true
@@ -131,7 +129,7 @@ struct NewChatSheet: View {
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
- Label("Create public channel (BETA)", systemImage: "antenna.radiowaves.left.and.right.circle.fill")
+ Label("Create public channel (BETA)", systemImage: "antenna.radiowaves.left.and.right")
}
}
diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift
index 63fb7f5221..9bcc326a66 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatView.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift
@@ -80,6 +80,7 @@ struct NewChatView: View {
@EnvironmentObject var theme: AppTheme
@State var selection: NewChatOption
@State var showQRCodeScanner = false
+ var onboarding: Bool = false
@State private var invitationUsed: Bool = false
@State private var connLinkInvitation: CreatedConnLink = CreatedConnLink(connFullLink: "", connShortLink: nil)
@State private var showShortLink = true
@@ -91,17 +92,19 @@ struct NewChatView: View {
var body: some View {
VStack(alignment: .leading) {
- Picker("New chat", selection: $selection) {
- Label("1-time link", systemImage: "link")
- .tag(NewChatOption.invite)
- Label("Connect via link", systemImage: "qrcode")
- .tag(NewChatOption.connect)
- }
- .pickerStyle(.segmented)
- .padding()
- .onChange(of: $selection.wrappedValue) { opt in
- if opt == NewChatOption.connect {
- showQRCodeScanner = true
+ if !onboarding {
+ Picker("New chat", selection: $selection) {
+ Label("1-time link", systemImage: "link")
+ .tag(NewChatOption.invite)
+ Label("Connect via link", systemImage: "qrcode")
+ .tag(NewChatOption.connect)
+ }
+ .pickerStyle(.segmented)
+ .padding()
+ .onChange(of: $selection.wrappedValue) { opt in
+ if opt == NewChatOption.connect {
+ showQRCodeScanner = true
+ }
}
}
@@ -116,7 +119,7 @@ struct NewChatView: View {
}
}
if case .connect = selection {
- ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert)
+ ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert, onboarding: onboarding)
.transition(.move(edge: .trailing))
}
}
@@ -141,16 +144,22 @@ struct NewChatView: View {
}
default: ()
}
- }
+ },
+ including: onboarding ? .subviews : .all
)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
- InfoSheetButton {
- AddContactLearnMore(showTitle: true)
+ if !onboarding {
+ InfoSheetButton {
+ AddContactLearnMore(showTitle: true)
+ }
+ } else {
+ Image(systemName: "info.circle").opacity(0)
}
}
}
+ .if(onboarding) { $0.navigationBarTitleDisplayMode(.inline) }
.modifier(ThemedBackground(grouped: true))
.onChange(of: invitationUsed) { used in
if used && !(m.showingInvitation?.connChatUsed ?? true) {
@@ -179,7 +188,8 @@ struct NewChatView: View {
contactConnection: $contactConnection,
connLinkInvitation: $connLinkInvitation,
showShortLink: $showShortLink,
- choosingProfile: $choosingProfile
+ choosingProfile: $choosingProfile,
+ onboarding: onboarding
)
} else if creatingConnReq {
creatingLinkProgressView()
@@ -239,6 +249,7 @@ private func incognitoProfileImage() -> some View {
}
private struct InviteView: View {
+ @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var invitationUsed: Bool
@@ -246,18 +257,19 @@ private struct InviteView: View {
@Binding var connLinkInvitation: CreatedConnLink
@Binding var showShortLink: Bool
@Binding var choosingProfile: Bool
+ var onboarding: Bool = false
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
var body: some View {
List {
- Section(header: Text("Share this 1-time invite link").foregroundColor(theme.colors.secondary)) {
+ Section(header: sectionHeader) {
shareLinkView()
}
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
qrCodeView()
- if let selectedProfile = chatModel.currentUser {
+ if !onboarding, let selectedProfile = chatModel.currentUser {
Section {
NavigationLink {
ActiveProfilePicker(
@@ -281,9 +293,9 @@ private struct InviteView: View {
} header: {
Text("Share profile").foregroundColor(theme.colors.secondary)
} footer: {
- if incognitoDefault {
- Text("A new random profile will be shared.")
- }
+ if incognitoDefault {
+ Text("A new random profile will be shared.")
+ }
}
}
}
@@ -295,8 +307,35 @@ private struct InviteView: View {
}
}
+ private var sectionHeader: some View {
+ #if SIMPLEX_ASSETS
+ VStack(alignment: .leading, spacing: 0) {
+ Image(colorScheme == .light
+ ? (onboarding ? "one-time-link" : "one-time-link-small")
+ : (onboarding ? "one-time-link-light" : "one-time-link-small-light"))
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ sectionHeaderText
+ }
+ .padding(.bottom, 6)
+ #else
+ sectionHeaderText
+ .if(onboarding) { $0.padding(.bottom, 6) }
+ #endif
+ }
+
+ @ViewBuilder private var sectionHeaderText: some View {
+ if onboarding {
+ Text("Send the link via any messenger - it's secure. Ask to paste into SimpleX.")
+ .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil)
+ } else {
+ Text("Share this 1-time invite link").foregroundColor(theme.colors.secondary)
+ }
+ }
+
private func shareLinkView() -> some View {
- HStack {
+ HStack(spacing: 8) {
let link = connLinkInvitation.simplexChatUri(short: showShortLink)
linkTextView(link)
Button {
@@ -305,6 +344,7 @@ private struct InviteView: View {
} label: {
Image(systemName: "square.and.arrow.up")
.padding(.top, -7)
+ .padding(.horizontal, 8)
}
}
.frame(maxWidth: .infinity)
@@ -324,7 +364,11 @@ private struct InviteView: View {
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
} header: {
- ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink)
+ if onboarding {
+ Text("Or show QR in person or via video call.").font(.body).foregroundColor(theme.colors.onBackground).textCase(nil)
+ } else {
+ ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink)
+ }
}
}
@@ -587,20 +631,24 @@ private struct ActiveProfilePicker: View {
}
private struct ConnectView: View {
+ @Environment(\.colorScheme) var colorScheme
@StateObject private var connectProgressManager = ConnectProgressManager.shared
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var showQRCodeScanner: Bool
@Binding var pastedLink: String
@Binding var alert: NewChatViewAlert?
+ var onboarding: Bool = false
@State var scannerPaused: Bool = false
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
var body: some View {
List {
- Section(header: Text("Paste the link you received").foregroundColor(theme.colors.secondary)) {
+ Section(header: connectSectionHeader) {
pasteLinkView()
}
+ .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
+
Section(header: Text("Or scan QR code").foregroundColor(theme.colors.secondary)) {
ScannerInView(showQRCodeScanner: $showQRCodeScanner, scannerPaused: $scannerPaused, processQRCode: processQRCode)
}
@@ -630,7 +678,7 @@ private struct ConnectView: View {
}
}
} label: {
- Text("Tap to paste link")
+ Text("Tap to paste link").foregroundColor(theme.colors.primary)
}
.disabled(!pasteboardHasStrings)
.frame(maxWidth: .infinity, alignment: .center)
@@ -669,6 +717,23 @@ private struct ConnectView: View {
}
}
+ private var connectSectionHeader: some View {
+ #if SIMPLEX_ASSETS
+ VStack(alignment: .leading, spacing: 0) {
+ Image(colorScheme == .light
+ ? (onboarding ? "connect-via-link" : "connect-via-link-small")
+ : (onboarding ? "connect-via-link-light" : "connect-via-link-small-light"))
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ Text("Paste the link you received").foregroundColor(theme.colors.secondary)
+ }
+ .padding(.bottom, 4)
+ #else
+ Text("Paste the link you received").foregroundColor(theme.colors.secondary)
+ #endif
+ }
+
private func connect(_ link: String) {
scannerPaused = true
planAndConnect(
@@ -765,7 +830,7 @@ struct ScannerInView: View {
}
-private func linkTextView(_ link: String) -> some View {
+func linkTextView(_ link: String) -> some View {
Text(link)
.lineLimit(1)
.font(.caption)
@@ -916,11 +981,13 @@ private func showAskCurrentOrIncognitoProfileSheet(
actionStyle: UIAlertAction.Style = .default,
connectionLink: CreatedConnLink,
connectionPlan: ConnectionPlan?,
+ ownerVerification: OwnerVerification? = nil,
dismiss: Bool,
cleanup: (() -> Void)?
) {
showSheet(
title,
+ message: ownerVerificationMessage(ownerVerification),
actions: {[
UIAlertAction(
title: NSLocalizedString("Use current profile", comment: "new chat action"),
@@ -1056,6 +1123,7 @@ private func showOwnGroupLinkConfirmConnectSheet(
private func showPrepareContactAlert(
connectionLink: CreatedConnLink,
contactShortLinkData: ContactShortLinkData,
+ ownerVerification: OwnerVerification? = nil,
theme: AppTheme,
dismiss: Bool,
cleanup: (() -> Void)?
@@ -1074,6 +1142,7 @@ private func showPrepareContactAlert(
size: alertProfileImageSize
),
theme: theme,
+ information: ownerVerificationMessage(ownerVerification),
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"),
onCancel: { cleanup?() },
@@ -1101,6 +1170,7 @@ private func showPrepareGroupAlert(
connectionLink: CreatedConnLink,
groupShortLinkInfo: GroupShortLinkInfo?,
groupShortLinkData: GroupShortLinkData,
+ ownerVerification: OwnerVerification? = nil,
theme: AppTheme,
dismiss: Bool,
cleanup: (() -> Void)?
@@ -1120,6 +1190,7 @@ private func showPrepareGroupAlert(
),
theme: theme,
subtitle: isChannel ? subscriberCount : nil,
+ information: ownerVerificationMessage(ownerVerification),
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
confirmTitle: isChannel
? NSLocalizedString("Open new channel", comment: "new chat action")
@@ -1217,6 +1288,7 @@ private func showOpenKnownGroupAlert(
// Spec: spec/client/navigation.md#planAndConnect
func planAndConnect(
_ shortOrFullLink: String,
+ linkOwnerSig: LinkOwnerSig? = nil,
theme: AppTheme,
dismiss: Bool,
cleanup: (() -> Void)? = nil,
@@ -1241,7 +1313,7 @@ func planAndConnect(
func connectTask(_ inProgress: BoxedValue) {
Task {
- let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink, inProgress: inProgress)
+ let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink, linkOwnerSig: linkOwnerSig, inProgress: inProgress)
await MainActor.run {
ConnectProgressManager.shared.stopConnectProgress()
}
@@ -1250,13 +1322,14 @@ func planAndConnect(
switch connectionPlan {
case let .invitationLink(ilp):
switch ilp {
- case let .ok(contactSLinkData_):
+ case let .ok(contactSLinkData_, ownerVerification):
if let contactSLinkData = contactSLinkData_ {
logger.debug("planAndConnect, .invitationLink, .ok, short link data present")
await MainActor.run {
showPrepareContactAlert(
connectionLink: connectionLink,
contactShortLinkData: contactSLinkData,
+ ownerVerification: ownerVerification,
theme: theme,
dismiss: dismiss,
cleanup: cleanup
@@ -1269,6 +1342,7 @@ func planAndConnect(
title: NSLocalizedString("Connect via one-time link", comment: "new chat sheet title"),
connectionLink: connectionLink,
connectionPlan: connectionPlan,
+ ownerVerification: ownerVerification,
dismiss: dismiss,
cleanup: cleanup
)
@@ -1311,13 +1385,14 @@ func planAndConnect(
}
case let .contactAddress(cap):
switch cap {
- case let .ok(contactSLinkData_):
+ case let .ok(contactSLinkData_, ownerVerification):
if let contactSLinkData = contactSLinkData_ {
logger.debug("planAndConnect, .contactAddress, .ok, short link data present")
await MainActor.run {
showPrepareContactAlert(
connectionLink: connectionLink,
contactShortLinkData: contactSLinkData,
+ ownerVerification: ownerVerification,
theme: theme,
dismiss: dismiss,
cleanup: cleanup
@@ -1330,6 +1405,7 @@ func planAndConnect(
title: NSLocalizedString("Connect via contact address", comment: "new chat sheet title"),
connectionLink: connectionLink,
connectionPlan: connectionPlan,
+ ownerVerification: ownerVerification,
dismiss: dismiss,
cleanup: cleanup
)
@@ -1389,7 +1465,7 @@ func planAndConnect(
}
case let .groupLink(glp):
switch glp {
- case let .ok(groupShortLinkInfo_, groupSLinkData_):
+ case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification):
if let groupSLinkData = groupSLinkData_ {
logger.debug("planAndConnect, .groupLink, .ok, short link data present")
await MainActor.run {
@@ -1397,6 +1473,7 @@ func planAndConnect(
connectionLink: connectionLink,
groupShortLinkInfo: groupShortLinkInfo_,
groupShortLinkData: groupSLinkData,
+ ownerVerification: ownerVerification,
theme: theme,
dismiss: dismiss,
cleanup: cleanup
@@ -1409,6 +1486,7 @@ func planAndConnect(
title: NSLocalizedString("Join group", comment: "new chat sheet title"),
connectionLink: connectionLink,
connectionPlan: connectionPlan,
+ ownerVerification: ownerVerification,
dismiss: dismiss,
cleanup: cleanup
)
@@ -1454,6 +1532,33 @@ func planAndConnect(
showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss)
}
}
+ case let .noRelays(groupSLinkData_):
+ logger.debug("planAndConnect, .groupLink, .noRelays")
+ await MainActor.run {
+ if let groupSLinkData = groupSLinkData_ {
+ showOpenChatAlert(
+ profileName: groupSLinkData.groupProfile.displayName,
+ profileFullName: groupSLinkData.groupProfile.fullName,
+ profileImage:
+ ProfileImage(
+ imageStr: groupSLinkData.groupProfile.image,
+ iconName: "antenna.radiowaves.left.and.right.circle.fill",
+ size: alertProfileImageSize
+ ),
+ theme: theme,
+ subtitle: NSLocalizedString("Channel has no active relays. Please try to join later.", comment: "alert subtitle"),
+ cancelTitle: NSLocalizedString("OK", comment: "alert button"),
+ confirmTitle: nil,
+ onCancel: { cleanup?() }
+ )
+ } else {
+ showAlert(
+ NSLocalizedString("Channel temporarily unavailable", comment: "alert title"),
+ message: NSLocalizedString("Channel has no active relays. Please try to join later.", comment: "alert message")
+ )
+ cleanup?()
+ }
+ }
}
case let .error(chatError):
logger.debug("planAndConnect, .error \(chatErrorString(chatError))")
@@ -1602,6 +1707,14 @@ private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType?
}
}
+private func ownerVerificationMessage(_ ov: OwnerVerification?) -> String? {
+ switch ov {
+ case .verified: NSLocalizedString("Link signature verified.", comment: "owner verification")
+ case let .failed(reason): String.localizedStringWithFormat(NSLocalizedString("⚠️ Signature verification failed: %@.", comment: "owner verification"), reason)
+ case .none: nil
+ }
+}
+
func connReqSentAlert(_ type: ConnReqType) -> Alert {
return mkAlert(
title: "Connection request sent!",
diff --git a/apps/ios/Shared/Views/NewChat/OnboardingCards.swift b/apps/ios/Shared/Views/NewChat/OnboardingCards.swift
new file mode 100644
index 0000000000..0a0b3c143d
--- /dev/null
+++ b/apps/ios/Shared/Views/NewChat/OnboardingCards.swift
@@ -0,0 +1,311 @@
+//
+// OnboardingCards.swift
+// SimpleX (iOS)
+//
+// Created by simplex-chat on 06.04.2026.
+// Copyright © 2026 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+// MARK: - Card component
+
+struct OnboardingCardView: View {
+ @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
+ @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ let imageName: String
+ let icon: String
+ let title: LocalizedStringKey
+ var subtitle: LocalizedStringKey? = nil
+ let labelHeightRatio: CGFloat
+ let action: () -> Void
+
+ static let lightStops: [Gradient.Stop] = [
+ .init(color: oklch(0.9219, 0.0431, 249.4), location: 0.0),
+ .init(color: oklch(0.9198, 0.0471, 240.7), location: 0.5),
+ .init(color: oklch(0.9772, 0.0358, 196.6), location: 0.9),
+ .init(color: oklch(0.9829, 0.0104, 70.0), location: 0.95),
+ .init(color: oklch(0.9886, 0.0272, 99.1), location: 1.0)
+ ]
+
+ static let darkStops: [Gradient.Stop] = [
+ .init(color: oklch(0.1578, 0.0609, 267.3), location: 0.4),
+ .init(color: oklch(0.4729, 0.1574, 267.3), location: 0.72),
+ .init(color: oklch(0.9024, 0.0760, 202.8), location: 0.9),
+ .init(color: oklch(0.9384, 0.0354, 65.0), location: 0.95),
+ .init(color: oklch(0.9744, 0.0370, 88.4), location: 1.0)
+ ]
+
+ static let gradientAngle: Double = 80.0 * .pi / 180.0
+
+ static func gradientPoints(aspectRatio: CGFloat, scale: CGFloat) -> (start: UnitPoint, end: UnitPoint) {
+ let r = Double(aspectRatio)
+ let s = Double(scale)
+ let dx = cos(gradientAngle)
+ let dy = -sin(gradientAngle) / r
+ let dLenSq = dx * dx + dy * dy
+ let projections = [
+ -0.5 * dx + (-0.5) * dy,
+ 0.5 * dx + (-0.5) * dy,
+ -0.5 * dx + 0.5 * dy,
+ 0.5 * dx + 0.5 * dy
+ ]
+ let tMin = projections.min()!
+ let tMax = projections.max()!
+ let startX = 0.5 + tMin * dx / dLenSq
+ let startY = 0.5 + tMin * dy / dLenSq
+ let endX = 0.5 + tMax * dx / dLenSq
+ let endY = 0.5 + tMax * dy / dLenSq
+ return (
+ start: .init(x: 0.5 + (startX - 0.5) * s, y: 0.5 + (startY - 0.5) * s),
+ end: .init(x: 0.5 + (endX - 0.5) * s, y: 0.5 + (endY - 0.5) * s)
+ )
+ }
+
+ var body: some View {
+ Button(action: action) {
+ GeometryReader { geo in
+ let labelHeight = geo.size.width * labelHeightRatio
+ let imageHeight = max(geo.size.height - labelHeight, 1)
+ let imageAspect = imageHeight / geo.size.width
+ let gp = Self.gradientPoints(aspectRatio: imageAspect, scale: colorScheme == .light ? 1.2 : 1.5)
+ VStack(spacing: 0) {
+ ZStack {
+ LinearGradient(
+ stops: colorScheme == .light ? Self.lightStops : Self.darkStops,
+ startPoint: gp.start,
+ endPoint: gp.end
+ )
+ #if SIMPLEX_ASSETS
+ Image(colorScheme == .light ? imageName : "\(imageName)-light")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .clipped()
+ #else
+ Image(systemName: icon)
+ .font(.system(size: imageHeight * 0.25))
+ .foregroundColor(theme.colors.primary)
+ #endif
+ }
+ .frame(height: imageHeight)
+
+ labelRow(height: labelHeight)
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+ }
+ .buttonStyle(.plain)
+ }
+
+ private func labelRow(height: CGFloat) -> some View {
+ VStack {
+ HStack {
+ #if SIMPLEX_ASSETS
+ Image(systemName: icon)
+ .font(.system(size: 24))
+ .foregroundColor(theme.colors.primary)
+ #endif
+ Text(title)
+ .font(.body)
+ .fontWeight(.medium)
+ .foregroundColor(theme.colors.onBackground)
+ .lineLimit(1)
+ .minimumScaleFactor(0.75)
+ }
+ if let subtitle {
+ Text(subtitle)
+ .font(.footnote)
+ .foregroundColor(theme.colors.onBackground.opacity(0.7))
+ }
+ }
+ .frame(height: height)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.horizontal, 16)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ }
+}
+
+// MARK: - Onboarding pager
+
+private let backButtonHeight: CGFloat = 44
+
+struct ConnectOnboardingView: View {
+ @EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.verticalSizeClass) private var verticalSizeClass
+ @State private var currentPage = 0
+ @State private var showConnectViaLink = false
+ @State private var showInviteSomeone = false
+ @State private var showCreateAddress = false
+
+ var body: some View {
+ TabView(selection: $currentPage) {
+ talkToSomeonePage.tag(0)
+ connectWithSomeonePage.tag(1)
+ }
+ .tabViewStyle(.page(indexDisplayMode: .never))
+ .sheet(isPresented: $showConnectViaLink) {
+ NavigationView {
+ NewChatView(selection: .connect, showQRCodeScanner: true, onboarding: true)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
+ }
+ .sheet(isPresented: $showInviteSomeone) {
+ NavigationView {
+ NewChatView(selection: .invite, onboarding: true)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
+ }
+ .sheet(isPresented: $showCreateAddress) {
+ NavigationView {
+ UserAddressView(autoCreate: true, onboarding: true)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil)
+ }
+ }
+
+ @ViewBuilder
+ private func cardPair(
+ _ geo: GeometryProxy,
+ @ViewBuilder card1: () -> C1,
+ @ViewBuilder card2: () -> C2
+ ) -> some View {
+ let padding: CGFloat = 20
+ let spacing: CGFloat = 20
+ let isLandscape = verticalSizeClass == .compact
+ let cardWidth = isLandscape
+ ? (geo.size.width - padding * 2 - spacing) / 2
+ : geo.size.width - padding * 2
+ let maxCardHeight = cardWidth * 0.75
+
+ if isLandscape {
+ HStack(spacing: spacing) {
+ card1().frame(maxHeight: maxCardHeight)
+ card2().frame(maxHeight: maxCardHeight)
+ }
+ .padding(.horizontal, padding)
+ } else {
+ VStack(spacing: spacing) {
+ card1().frame(maxHeight: maxCardHeight)
+ card2().frame(maxHeight: maxCardHeight)
+ }
+ .padding(.horizontal, padding)
+ }
+ }
+
+ // MARK: Screen 1
+
+ @ViewBuilder
+ private func pageHeader(_ title: LocalizedStringKey, showBack: Bool) -> some View {
+ let isLandscape = verticalSizeClass == .compact
+ let titleView = Text(title)
+ .font(.largeTitle)
+ .bold()
+ .lineLimit(1)
+ .minimumScaleFactor(0.67)
+ .frame(maxWidth: .infinity, alignment: .center)
+ if isLandscape {
+ ZStack(alignment: .leading) {
+ if showBack { backButton }
+ titleView
+ }
+ .padding(.horizontal, 16)
+ } else {
+ VStack(spacing: 0) {
+ if showBack {
+ backButton.frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ Color.clear.frame(height: backButtonHeight)
+ }
+ titleView
+ }
+ .padding(.horizontal, 16)
+ }
+ }
+
+ private var backButton: some View {
+ Button {
+ withAnimation { currentPage = 0 }
+ } label: {
+ HStack(spacing: 4) {
+ Image(systemName: "chevron.left")
+ Text("Back")
+ }
+ }
+ .frame(height: backButtonHeight)
+ }
+
+ // MARK: Screen 1
+
+ private var talkToSomeonePage: some View {
+ GeometryReader { geo in
+ VStack(spacing: 0) {
+ pageHeader("Talk to someone", showBack: false)
+
+ Spacer(minLength: 16)
+
+ cardPair(geo) {
+ OnboardingCardView(
+ imageName: "card-let-someone-connect-to-you-alpha",
+ icon: "link.badge.plus",
+ title: "Let someone connect to you",
+ labelHeightRatio: 0.132,
+ action: { withAnimation { currentPage = 1 } }
+ )
+ } card2: {
+ OnboardingCardView(
+ imageName: "card-connect-via-link-alpha",
+ icon: "qrcode.viewfinder",
+ title: "Connect via link or QR code",
+ labelHeightRatio: 0.132,
+ action: { showConnectViaLink = true }
+ )
+ }
+
+ Spacer(minLength: 16)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+
+ // MARK: Screen 2
+
+ private var connectWithSomeonePage: some View {
+ GeometryReader { geo in
+ VStack(spacing: 0) {
+ pageHeader("Create your link", showBack: true)
+
+ Spacer(minLength: 16)
+
+ cardPair(geo) {
+ OnboardingCardView(
+ imageName: "card-invite-someone-privately-alpha",
+ icon: "link.badge.plus",
+ title: "Invite someone privately",
+ subtitle: "A link for one person to connect",
+ labelHeightRatio: 0.195,
+ action: { showInviteSomeone = true }
+ )
+ } card2: {
+ OnboardingCardView(
+ imageName: "card-create-your-public-address-alpha",
+ icon: "qrcode",
+ title: m.userAddress != nil ? "Your public address" : "Create your public address",
+ subtitle: "For anyone to reach you",
+ labelHeightRatio: 0.195,
+ action: { showCreateAddress = true }
+ )
+ }
+
+ Spacer(minLength: 16)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift
deleted file mode 100644
index f22d59fcac..0000000000
--- a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift
+++ /dev/null
@@ -1,110 +0,0 @@
-//
-// AddressCreationCard.swift
-// SimpleX (iOS)
-//
-// Created by Diogo Cunha on 13/11/2024.
-// Copyright © 2024 SimpleX Chat. All rights reserved.
-//
-// Spec: spec/client/navigation.md
-
-import SwiftUI
-import SimpleXChat
-
-struct AddressCreationCard: View {
- @EnvironmentObject var theme: AppTheme
- @EnvironmentObject private var chatModel: ChatModel
- @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
- @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
- @State private var showAddressCreationAlert = false
- @State private var showAddressSheet = false
- @State private var showAddressInfoSheet = false
-
- var body: some View {
- let addressExists = chatModel.userAddress != nil
- let chats = chatModel.chats.filter { chat in
- !chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard
- }
- ZStack(alignment: .topTrailing) {
- HStack(alignment: .top, spacing: 16) {
- let envelopeSize = dynamicSize(userFont).profileImageSize
- Image(systemName: "envelope.circle.fill")
- .resizable()
- .frame(width: envelopeSize, height: envelopeSize)
- .foregroundColor(.accentColor)
- VStack(alignment: .leading) {
- Text("Your SimpleX address")
- .font(.title3)
- Spacer()
- Text("How to use it") + textSpace + Text(Image(systemName: "info.circle")).foregroundColor(theme.colors.secondary)
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- VStack(alignment: .trailing) {
- Image(systemName: "multiply")
- .foregroundColor(theme.colors.secondary)
- .onTapGesture {
- showAddressCreationAlert = true
- }
- Spacer()
- Text("Create")
- .foregroundColor(.accentColor)
- .onTapGesture {
- showAddressSheet = true
- }
- }
- }
- .onTapGesture {
- showAddressInfoSheet = true
- }
- .padding()
- .background(theme.appColors.sentMessage)
- .cornerRadius(12)
- .frame(height: dynamicSize(userFont).rowHeight)
- .alert(isPresented: $showAddressCreationAlert) {
- Alert(
- title: Text("SimpleX address"),
- message: Text("Tap Create SimpleX address in the menu to create it later."),
- dismissButton: .default(Text("Ok")) {
- withAnimation {
- addressCreationCardShown = true
- }
- }
- )
- }
- .sheet(isPresented: $showAddressSheet) {
- NavigationView {
- UserAddressView(autoCreate: true)
- .navigationTitle("SimpleX address")
- .navigationBarTitleDisplayMode(.large)
- .modifier(ThemedBackground(grouped: true))
- }
- }
- .sheet(isPresented: $showAddressInfoSheet) {
- NavigationView {
- UserAddressLearnMore(showCreateAddressButton: true)
- .navigationTitle("Address or 1-time link?")
- .navigationBarTitleDisplayMode(.inline)
- .modifier(ThemedBackground(grouped: true))
- }
- }
- .onChange(of: addressExists) { exists in
- if exists, !addressCreationCardShown {
- addressCreationCardShown = true
- }
- }
- .onChange(of: chats.count) { size in
- if size >= 3, !addressCreationCardShown {
- addressCreationCardShown = true
- }
- }
- .onAppear {
- if addressExists, !addressCreationCardShown {
- addressCreationCardShown = true
- }
- }
- }
-}
-
-#Preview {
- AddressCreationCard()
-}
diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift
index b5598c1f85..b61b81a46b 100644
--- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift
+++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift
@@ -44,160 +44,147 @@ struct OnboardingButtonStyle: ButtonStyle {
}
}
-private enum OnboardingConditionsViewSheet: Identifiable {
- case showConditions
- case configureOperators
-
- var id: String {
- switch self {
- case .showConditions: return "showConditions"
- case .configureOperators: return "configureOperators"
- }
- }
-}
-
struct OnboardingConditionsView: View {
@EnvironmentObject var theme: AppTheme
- @State private var serverOperators: [ServerOperator] = []
- @State private var selectedOperatorIds = Set()
- @State private var sheetItem: OnboardingConditionsViewSheet? = nil
- @State private var notificationsModeNavLinkActive = false
- @State private var justOpened = true
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
+ @State private var showConditionsSheet = false
+ var selectedOperatorIds: Set
var body: some View {
GeometryReader { g in
- let v = ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- Text("Conditions of use")
- .font(.largeTitle)
- .bold()
- .frame(maxWidth: .infinity, alignment: .center)
- .padding(.top, 25)
+ VStack(alignment: .leading, spacing: 10) {
+ Spacer(minLength: 0)
- Spacer()
+ heroImage().frame(maxWidth: .infinity, minHeight: 80)
- VStack(alignment: .leading, spacing: 20) {
- Text("Private chats, groups and your contacts are not accessible to server operators.")
- .lineSpacing(2)
- .frame(maxWidth: .infinity, alignment: .leading)
- Text("""
- By using SimpleX Chat you agree to:
- - send only legal content in public groups.
- - respect other users – no spam.
- """)
- .lineSpacing(2)
- .frame(maxWidth: .infinity, alignment: .leading)
+ Text("Network commitments")
+ .font(.largeTitle)
+ .bold()
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .fixedSize(horizontal: false, vertical: true)
- Button("Privacy policy and conditions of use.") {
- sheetItem = .showConditions
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .padding(.horizontal, 4)
+ Text("Operators commit to:\n- Be independent\n- Minimize metadata usage\n- Run verified open-source code")
+ .font(.callout)
+ .lineSpacing(2)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, 4)
+ .padding(.top, 10)
+ .fixedSize(horizontal: false, vertical: true)
- Spacer()
+ Text("You commit to:\n- Only legal content in public groups\n- Respect other users - no spam")
+ .font(.callout)
+ .lineSpacing(2)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, 4)
+ .padding(.top, 10)
+ .fixedSize(horizontal: false, vertical: true)
- VStack(spacing: 12) {
- acceptConditionsButton()
-
- Button("Configure server operators") {
- sheetItem = .configureOperators
- }
- .frame(minHeight: 40)
- }
+ Button {
+ showConditionsSheet = true
+ } label: {
+ Text("Privacy policy and conditions of use.")
+ .fontWeight(.medium)
+ .fixedSize(horizontal: false, vertical: true)
}
- .padding(25)
- .frame(minHeight: g.size.height)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, 4)
+ .padding(.top, 10)
+ .padding(.bottom, 15)
+
+ Spacer(minLength: 0)
+
+ acceptButton()
+ .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
- .onAppear {
- if justOpened {
- serverOperators = ChatModel.shared.conditions.serverOperators
- selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
- justOpened = false
+ .padding(.horizontal, 25)
+ .padding(.top, 25)
+ .padding(.bottom, 25)
+ .frame(minHeight: g.size.height)
+ }
+ .frame(maxHeight: .infinity)
+ .navigationBarHidden(true)
+ .sheet(isPresented: $showConditionsSheet) {
+ NavigationView {
+ VStack {
+ ConditionsTextView()
+ .padding()
+ acceptButton()
+ .padding(.horizontal, 25)
+ .padding(.bottom, 20)
}
- }
- .sheet(item: $sheetItem) { item in
- switch item {
- case .showConditions:
- SimpleConditionsView()
- .modifier(ThemedBackground(grouped: true))
- case .configureOperators:
- ChooseServerOperators(serverOperators: serverOperators, selectedOperatorIds: $selectedOperatorIds)
- .modifier(ThemedBackground())
- }
- }
- .frame(maxHeight: .infinity, alignment: .top)
- if #available(iOS 16.4, *) {
- v.scrollBounceBehavior(.basedOnSize)
- } else {
- v
+ .navigationTitle("Conditions of use")
+ .navigationBarTitleDisplayMode(.large)
+ .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
+ .modifier(ThemedBackground(grouped: true))
}
}
- .frame(maxHeight: .infinity, alignment: .top)
- .navigationBarHidden(true) // necessary on iOS 15
}
- private func continueToNextStep() {
- onboardingStageDefault.set(.step4_SetNotificationsMode)
- notificationsModeNavLinkActive = true
- }
-
- func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View {
+ @ViewBuilder
+ private func heroImage() -> some View {
+ #if SIMPLEX_ASSETS
+ Image(colorScheme == .light ? "network-commitments" : "network-commitments-light")
+ .resizable()
+ .scaledToFit()
+ #else
ZStack {
- button()
-
- NavigationLink(isActive: $notificationsModeNavLinkActive) {
- notificationsModeDestinationView()
- } label: {
- EmptyView()
- }
- .frame(width: 1, height: 1)
- .hidden()
+ let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.5, scale: colorScheme == .light ? 1.2 : 1.5)
+ LinearGradient(
+ stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops,
+ startPoint: gp.start,
+ endPoint: gp.end
+ )
+ Image(systemName: "checkmark.shield")
+ .font(.system(size: 72))
+ .foregroundColor(theme.colors.primary)
}
+ .aspectRatio(1.5, contentMode: .fit)
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+ .padding(.horizontal, 25)
+ #endif
}
- private func notificationsModeDestinationView() -> some View {
- SetNotificationsMode()
- .navigationBarBackButtonHidden(true)
- .modifier(ThemedBackground())
- }
-
- private func acceptConditionsButton() -> some View {
- notificationsModeNavLinkButton {
- Button {
- Task {
- do {
- let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
- let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: Array(selectedOperatorIds))
+ private func acceptButton() -> some View {
+ Button {
+ Task {
+ do {
+ let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
+ let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: Array(selectedOperatorIds))
+ await MainActor.run {
+ ChatModel.shared.conditions = r
+ }
+ if let enabledOps = enabledOperators(r.serverOperators) {
+ let r2 = try await setServerOperators(operators: enabledOps)
await MainActor.run {
- ChatModel.shared.conditions = r
+ ChatModel.shared.conditions = r2
+ completeOnboarding()
}
- if let enabledOperators = enabledOperators(r.serverOperators) {
- let r2 = try await setServerOperators(operators: enabledOperators)
- await MainActor.run {
- ChatModel.shared.conditions = r2
- continueToNextStep()
- }
- } else {
- await MainActor.run {
- continueToNextStep()
- }
- }
- } catch let error {
+ } else {
await MainActor.run {
- showAlert(
- NSLocalizedString("Error accepting conditions", comment: "alert title"),
- message: responseError(error)
- )
+ completeOnboarding()
}
}
+ } catch let error {
+ await MainActor.run {
+ showAlert(
+ NSLocalizedString("Error accepting conditions", comment: "alert title"),
+ message: responseError(error)
+ )
+ }
}
- } label: {
- Text("Accept")
}
- .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
- .disabled(selectedOperatorIds.isEmpty)
+ } label: {
+ Text("Accept")
}
+ .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
+ .disabled(selectedOperatorIds.isEmpty)
+ }
+
+ private func completeOnboarding() {
+ let m = ChatModel.shared
+ onboardingStageDefault.set(.onboardingComplete)
+ m.onboardingStage = .onboardingComplete
}
private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? {
@@ -222,7 +209,7 @@ struct OnboardingConditionsView: View {
if !haveXFTPProxy { op.xftpRoles.proxy = true }
ops[firstEnabledIndex] = op
return ops
- } else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled
+ } else {
return nil
}
} else {
@@ -405,5 +392,5 @@ struct ChooseServerOperatorsInfoView: View {
}
#Preview {
- OnboardingConditionsView()
+ OnboardingConditionsView(selectedOperatorIds: [])
}
diff --git a/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift b/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift
new file mode 100644
index 0000000000..87f66a72bb
--- /dev/null
+++ b/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift
@@ -0,0 +1,114 @@
+//
+// ConnectBannerCard.swift
+// SimpleX (iOS)
+//
+// Copyright © 2026 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+private let bannerImageRatio: CGFloat = 800 / 505
+
+struct ConnectBannerCard: View {
+ @Environment(\.colorScheme) var colorScheme
+ @EnvironmentObject var theme: AppTheme
+ @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
+ @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
+ @State private var showNewLink = false
+ @State private var showPasteLink = false
+
+ var body: some View {
+ VStack(alignment: .trailing, spacing: 3) {
+ Button {
+ withAnimation { addressCreationCardShown = true }
+ } label: {
+ Image(systemName: "multiply")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(theme.colors.secondary)
+ .frame(width: 30, height: 30)
+ .background(theme.colors.onBackground.opacity(0.08), in: Circle())
+ }
+ HStack(spacing: 2) {
+ bannerHalf(
+ imageName: "banner-create-link",
+ icon: "link.badge.plus",
+ title: "New 1-time link",
+ action: { showNewLink = true }
+ )
+ bannerHalf(
+ imageName: "banner-paste-link",
+ icon: "qrcode.viewfinder",
+ title: "Paste link / Scan",
+ action: { showPasteLink = true }
+ )
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 18))
+ }
+ .sheet(isPresented: $showNewLink) {
+ NavigationView {
+ NewChatView(selection: .invite)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+ .sheet(isPresented: $showPasteLink) {
+ NavigationView {
+ NewChatView(selection: .connect, showQRCodeScanner: true)
+ .modifier(ThemedBackground(grouped: true))
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func bannerHalf(imageName: String, icon: String, title: LocalizedStringKey, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ VStack(spacing: 0) {
+ #if SIMPLEX_ASSETS
+ Image(colorScheme == .light ? imageName : "\(imageName)-light")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ #else
+ gradientFallback(icon: icon)
+ #endif
+ HStack(spacing: 8) {
+ #if SIMPLEX_ASSETS
+ Image(systemName: icon)
+ .font(.system(size: 18))
+ .foregroundColor(theme.colors.primary)
+ #endif
+ Text(title)
+ .font(.footnote)
+ .foregroundColor(theme.colors.onBackground)
+ .lineLimit(1)
+ .minimumScaleFactor(0.75)
+ }
+ .frame(height: 20)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ .background(ToolbarMaterial.material(toolbarMaterial))
+ }
+ }
+ .buttonStyle(.plain)
+ }
+
+ @ViewBuilder
+ private func gradientFallback(icon: String) -> some View {
+ let gp = OnboardingCardView.gradientPoints(
+ aspectRatio: 1 / bannerImageRatio,
+ scale: colorScheme == .light ? 1.2 : 1.5
+ )
+ ZStack {
+ LinearGradient(
+ stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops,
+ startPoint: gp.start,
+ endPoint: gp.end
+ )
+ Image(systemName: icon)
+ .font(.system(size: 40))
+ .foregroundColor(theme.colors.primary)
+ }
+ .aspectRatio(bannerImageRatio, contentMode: .fit)
+ .frame(maxWidth: .infinity)
+ }
+}
diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
index 7301c0421d..3c33546436 100644
--- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
+++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
@@ -29,45 +29,82 @@ enum UserProfileAlert: Identifiable {
let MAX_BIO_LENGTH_BYTES = 160
struct CreateProfile: View {
+ @Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@State private var displayName: String = ""
@State private var profileBio: String = ""
@FocusState private var focusDisplayName
@State private var alert: UserProfileAlert?
+ @State private var showChooseSource = false
+ @State private var showImagePicker = false
+ @State private var showTakePhoto = false
+ @State private var chosenImage: UIImage? = nil
+ @State private var profileImage: String? = nil
var body: some View {
List {
+ Group {
+ HStack(spacing: 0) {
+ Spacer(minLength: 0)
+ ZStack(alignment: .center) {
+ ZStack(alignment: .topTrailing) {
+ ProfileImage(imageStr: profileImage, size: 128)
+ if profileImage != nil {
+ Button {
+ profileImage = nil
+ } label: {
+ Image(systemName: "multiply")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 12)
+ }
+ }
+ }
+
+ editImageButton { showChooseSource = true }
+ .buttonStyle(BorderlessButtonStyle())
+ }
+ .padding(.horizontal, 10) // Offsets transparent space built into 3D asset
+ Spacer(minLength: 0)
+ #if SIMPLEX_ASSETS
+ Image(colorScheme == .light ? "create-profile" : "create-profile-light")
+ .resizable()
+ .scaledToFit()
+ .frame(height: 140)
+ // No trailing spacer — asset image has empty space on the right
+ #endif
+ }
+ }
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0))
+
Section {
- TextField("Enter your name…", text: $displayName)
- .focused($focusDisplayName)
- TextField("Bio", text: $profileBio)
- Button {
- createProfile()
- } label: {
- Label("Create profile", systemImage: "checkmark")
+ ZStack(alignment: .leading) {
+ let name = displayName.trimmingCharacters(in: .whitespaces)
+ if name != mkValidName(name) {
+ Button {
+ alert = .invalidNameError(validName: mkValidName(name))
+ } label: {
+ Image(systemName: "exclamationmark.circle").foregroundColor(.red)
+ }
+ } else {
+ Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
+ }
+ TextField("Enter your name…", text: $displayName)
+ .padding(.leading, 36)
+ .focused($focusDisplayName)
+ }
+ ZStack(alignment: .leading) {
+ Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
+ TextField("Bio", text: $profileBio)
+ .padding(.leading, 36)
+ }
+ Button(action: createProfile) {
+ settingsRow("checkmark", color: theme.colors.primary) { Text("Create profile") }
}
.disabled(!canCreateProfile(displayName) || !bioFitsLimit())
- } header: {
- HStack {
- Text("Your profile")
- .foregroundColor(theme.colors.secondary)
-
- let name = displayName.trimmingCharacters(in: .whitespaces)
- let validName = mkValidName(name)
- if name != validName {
- Spacer()
- validationErrorIndicator {
- alert = .invalidNameError(validName: validName)
- }
- } else if !bioFitsLimit() {
- Spacer()
- validationErrorIndicator {
- showAlert(NSLocalizedString("Bio too large", comment: "alert title"))
- }
- }
- }
- .frame(height: 20)
} footer: {
VStack(alignment: .leading, spacing: 8) {
Text("Your profile is stored on your device and only shared with your contacts.")
@@ -75,10 +112,42 @@ struct CreateProfile: View {
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
+ .compactSectionSpacing()
}
.navigationTitle("Create your profile")
.modifier(ThemedBackground(grouped: true))
.alert(item: $alert) { a in userProfileAlert(a, $displayName) }
+ .confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) {
+ Button("Take picture") {
+ showTakePhoto = true
+ }
+ Button("Choose from library") {
+ showImagePicker = true
+ }
+ }
+ .fullScreenCover(isPresented: $showTakePhoto) {
+ ZStack {
+ Color.black.edgesIgnoringSafeArea(.all)
+ CameraImagePicker(image: $chosenImage)
+ }
+ }
+ .sheet(isPresented: $showImagePicker) {
+ LibraryImagePicker(image: $chosenImage) { _ in
+ await MainActor.run {
+ showImagePicker = false
+ }
+ }
+ }
+ .onChange(of: chosenImage) { image in
+ Task {
+ let resized: String? = if let image {
+ await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
+ } else {
+ nil
+ }
+ await MainActor.run { profileImage = resized }
+ }
+ }
.onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
focusDisplayName = true
@@ -86,14 +155,6 @@ struct CreateProfile: View {
}
}
- private func validationErrorIndicator(_ onTap: @escaping () -> Void) -> some View {
- Image(systemName: "exclamationmark.circle")
- .foregroundColor(.red)
- .onTapGesture {
- onTap()
- }
- }
-
private func bioFitsLimit() -> Bool {
chatJsonLength(profileBio) <= MAX_BIO_LENGTH_BYTES
}
@@ -104,7 +165,8 @@ struct CreateProfile: View {
let profile = Profile(
displayName: displayName.trimmingCharacters(in: .whitespaces),
fullName: "",
- shortDescr: shortDescr
+ shortDescr: shortDescr,
+ image: profileImage
)
let m = ChatModel.shared
do {
@@ -133,61 +195,103 @@ struct CreateProfile: View {
struct CreateFirstProfile: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
- @Environment(\.dismiss) var dismiss
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
@State private var displayName: String = ""
@FocusState private var focusDisplayName
@State private var nextStepNavLinkActive = false
-
+ @State private var showMigrateSheet = false
var body: some View {
- let v = VStack(alignment: .leading, spacing: 16) {
- VStack(alignment: .center, spacing: 16) {
- Text("Create profile")
- .font(.largeTitle)
- .bold()
- .multilineTextAlignment(.center)
-
- Text("Your profile is stored on your device and only shared with your contacts.")
- .font(.callout)
- .foregroundColor(theme.colors.secondary)
- .multilineTextAlignment(.center)
- }
- .fixedSize(horizontal: false, vertical: true)
- .frame(maxWidth: .infinity) // Ensures it takes up the full width
- .padding(.horizontal, 10)
- .onTapGesture { focusDisplayName = false }
-
- HStack {
- let name = displayName.trimmingCharacters(in: .whitespaces)
- let validName = mkValidName(name)
- ZStack(alignment: .trailing) {
- TextField("Enter your name…", text: $displayName)
- .focused($focusDisplayName)
- .padding(.horizontal)
- .padding(.trailing, 20)
- .padding(.vertical, 10)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(Color(uiColor: .tertiarySystemFill))
+ let spacing: CGFloat = 10
+ let topPadding: CGFloat = 8
+ let padding: CGFloat = 25
+ GeometryReader { g in
+ let v = ScrollView {
+ VStack(alignment: .center, spacing: spacing) {
+ #if SIMPLEX_ASSETS
+ Image(colorScheme == .light ? "your-profile" : "your-profile-light")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ #else
+ ZStack {
+ let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5)
+ LinearGradient(
+ stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops,
+ startPoint: gp.start,
+ endPoint: gp.end
)
- if name != validName {
- Button {
- showAlert(.invalidNameError(validName: validName))
- } label: {
- Image(systemName: "exclamationmark.circle")
- .foregroundColor(.red)
- .padding(.horizontal, 10)
- }
+ Image(systemName: "person.crop.rectangle")
+ .font(.system(size: 72))
+ .foregroundColor(theme.colors.primary)
}
+ .aspectRatio(1.0, contentMode: .fit)
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+ .padding(.horizontal, 25)
+ .frame(maxWidth: .infinity)
+ #endif
+
+ Text("Your profile")
+ .font(.largeTitle)
+ .bold()
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Text("On your phone, not on servers.")
+ .font(.title3)
+ .fontWeight(.medium)
+ .foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Text("No account. No phone. No email. No ID.\nThe most secure encryption.")
+ .font(.footnote)
+ .foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+
+ profileNameField()
+ .padding(.top)
+ .padding(.bottom, 5)
+
+ Spacer(minLength: 0)
+
+ createProfileButton()
+ .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
+ }
+ .padding(.horizontal, padding)
+ .padding(.top, topPadding)
+ .padding(.bottom, padding)
+ .frame(minHeight: g.size.height)
+ }
+ .onTapGesture { focusDisplayName = false }
+ .sheet(isPresented: $showMigrateSheet, onDismiss: { m.migrationState = nil }) {
+ NavigationView {
+ MigrateToDevice(migrationState: $m.migrationState)
+ .navigationTitle("Migrate here")
+ .modifier(ThemedBackground(grouped: true))
}
}
- .padding(.top)
-
- Spacer()
-
- VStack(spacing: 10) {
- createProfileButton()
- if !focusDisplayName {
- onboardingButtonPlaceholder()
+ if #available(iOS 17, *) {
+ v.scrollBounceBehavior(.basedOnSize).defaultScrollAnchor(.bottom)
+ } else if #available(iOS 16.4, *) {
+ v.scrollBounceBehavior(.basedOnSize)
+ } else {
+ v
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ if m.migrationState == nil {
+ m.migrationState = .pasteOrScanLink
+ }
+ showMigrateSheet = true
+ } label: {
+ HStack(spacing: 4) {
+ Image(systemName: "tray.and.arrow.down")
+ Text("Migrate")
+ .fontWeight(.medium)
+ }
}
}
}
@@ -195,23 +299,40 @@ struct CreateFirstProfile: View {
if #available(iOS 16, *) {
focusDisplayName = true
} else {
- // it does not work before animation completes on iOS 15
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
focusDisplayName = true
}
}
}
- .padding(.horizontal, 25)
- .padding(.bottom, 25)
- .frame(maxWidth: .infinity, alignment: .leading)
- if #available(iOS 16, *) {
- return v.padding(.top, 10)
- } else {
- return v.padding(.top, 75).ignoresSafeArea(.all, edges: .top)
+ .frame(maxHeight: .infinity)
+ }
+
+ private func profileNameField() -> some View {
+ let name = displayName.trimmingCharacters(in: .whitespaces)
+ let validName = mkValidName(name)
+ return ZStack(alignment: .trailing) {
+ TextField("Enter profile name...", text: $displayName)
+ .focused($focusDisplayName)
+ .padding(.horizontal)
+ .padding(.trailing, name != validName ? 20 : 0)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color(uiColor: .tertiarySystemFill))
+ )
+ if name != validName {
+ Button {
+ showAlert(.invalidNameError(validName: validName))
+ } label: {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundColor(.red)
+ .padding(.horizontal, 10)
+ }
+ }
}
}
- func createProfileButton() -> some View {
+ private func createProfileButton() -> some View {
ZStack {
Button {
createProfile()
@@ -236,7 +357,7 @@ struct CreateFirstProfile: View {
}
private func nextStepDestinationView() -> some View {
- OnboardingConditionsView()
+ YourNetworkView()
.navigationBarBackButtonHidden(true)
.modifier(ThemedBackground())
}
diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
index 263b55a42d..e9b9c6b970 100644
--- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
+++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift
@@ -9,7 +9,7 @@
import SwiftUI
-struct HowItWorks: View {
+struct OldHowItWorks: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var m: ChatModel
var onboarding: Bool
@@ -28,7 +28,7 @@ struct HowItWorks: View {
Text("Only client devices store user profiles, contacts, groups, and messages.")
Text("All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages.")
if !onboarding {
- Text("Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).")
+ ExternalLink("Read more in our GitHub repository.", destination: URL(string: "https://github.com/simplex-chat/simplex-chat#readme")!)
}
}
.padding(.bottom)
@@ -61,9 +61,57 @@ struct HowItWorks: View {
}
}
-struct HowItWorks_Previews: PreviewProvider {
+struct WhySimpleX: View {
+ @Environment(\.dismiss) var dismiss: DismissAction
+ @EnvironmentObject var m: ChatModel
+ var onboarding: Bool
+ @Binding var createProfileNavLinkActive: Bool
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("You were born without an account")
+ .font(.title)
+ .bold()
+ .padding(.top)
+ Text("Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life.")
+ Text("Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible.")
+ Text("There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected.")
+ Text("Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign.")
+ Text("Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public.")
+ Text("The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it.")
+ Text("Because we destroyed the power to know who you are. So that your power can never be taken.")
+ Text("Be free in your network.")
+ }
+ }
+ .padding(.bottom, 16)
+
+ Spacer()
+
+ if onboarding {
+ createFirstProfileButton()
+ }
+ }
+ .padding(onboarding ? 25 : 16)
+ .frame(maxHeight: .infinity, alignment: .top)
+ .modifier(ThemedBackground())
+ }
+
+ private func createFirstProfileButton() -> some View {
+ Button {
+ dismiss()
+ createProfileNavLinkActive = true
+ } label: {
+ Text("Get started")
+ }
+ .buttonStyle(OnboardingButtonStyle(isDisabled: false))
+ }
+}
+
+struct WhySimpleX_Previews: PreviewProvider {
static var previews: some View {
- HowItWorks(
+ WhySimpleX(
onboarding: true,
createProfileNavLinkActive: Binding.constant(false)
)
diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
index daef95fbc6..39ccabce04 100644
--- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
+++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift
@@ -19,17 +19,18 @@ struct OnboardingView: View {
case .step1_SimpleXInfo:
SimpleXInfo(onboarding: true)
.modifier(ThemedBackground())
- case .step2_CreateProfile: // deprecated
+ case .step2_CreateProfile:
CreateFirstProfile()
.modifier(ThemedBackground())
case .step3_CreateSimpleXAddress: // deprecated
CreateSimpleXAddress()
- case .step3_ChooseServerOperators:
- OnboardingConditionsView()
+ case .step3_ChooseServerOperators,
+ .step4_SetNotificationsMode: // deprecated
+ YourNetworkView()
.navigationBarBackButtonHidden(true)
.modifier(ThemedBackground())
- case .step4_SetNotificationsMode:
- SetNotificationsMode()
+ case .step4_NetworkCommitments:
+ OnboardingConditionsView(selectedOperatorIds: Set(ChatModel.shared.conditions.serverOperators.filter { $0.enabled }.map { $0.operatorId }))
.navigationBarBackButtonHidden(true)
.modifier(ThemedBackground())
case .onboardingComplete: EmptyView()
@@ -45,10 +46,11 @@ func onboardingButtonPlaceholder() -> some View {
// Spec: spec/client/navigation.md#onboardingStage
enum OnboardingStage: String, Identifiable {
case step1_SimpleXInfo
- case step2_CreateProfile // deprecated
+ case step2_CreateProfile
case step3_CreateSimpleXAddress // deprecated
- case step3_ChooseServerOperators // changed to simplified conditions
- case step4_SetNotificationsMode
+ case step3_ChooseServerOperators
+ case step4_SetNotificationsMode // deprecated
+ case step4_NetworkCommitments
case onboardingComplete
public var id: Self { self }
diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift
index 717405b03b..1a1f1bb68c 100644
--- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift
+++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift
@@ -11,45 +11,39 @@ import SwiftUI
import SimpleXChat
struct SetNotificationsMode: View {
- @EnvironmentObject var m: ChatModel
- @State private var notificationMode = NotificationsMode.instant
- @State private var showAlert: NotificationAlert?
- @State private var showInfo: Bool = false
+ @Environment(\.dismiss) var dismiss
+ @Binding var notificationMode: NotificationsMode
+ @State private var showInfo = false
var body: some View {
GeometryReader { g in
- let v = ScrollView {
+ ScrollView {
VStack(alignment: .center, spacing: 20) {
Text("Push notifications")
.font(.largeTitle)
.bold()
.padding(.top, 25)
-
- infoText()
-
+
+ Button {
+ showInfo = true
+ } label: {
+ Label("How it affects privacy", systemImage: "info.circle")
+ .font(.headline)
+ }
+
Spacer()
ForEach(NotificationsMode.values) { mode in
NtfModeSelector(mode: mode, selection: $notificationMode)
}
-
+
Spacer()
-
+
VStack(spacing: 10) {
Button {
- if let token = m.deviceToken {
- setNotificationsMode(token, notificationMode)
- } else {
- AlertManager.shared.showAlertMsg(title: "No device token!")
- }
- onboardingStageDefault.set(.onboardingComplete)
- m.onboardingStage = .onboardingComplete
+ dismiss()
} label: {
- if case .off = notificationMode {
- Text("Use chat")
- } else {
- Text("Enable notifications")
- }
+ Text("OK")
}
.buttonStyle(OnboardingButtonStyle())
onboardingButtonPlaceholder()
@@ -58,50 +52,11 @@ struct SetNotificationsMode: View {
.padding(25)
.frame(minHeight: g.size.height)
}
- if #available(iOS 16.4, *) {
- v.scrollBounceBehavior(.basedOnSize)
- } else {
- v
- }
}
.frame(maxHeight: .infinity)
.sheet(isPresented: $showInfo) {
NotificationsInfoView()
}
- .navigationBarHidden(true) // necessary on iOS 15
- }
-
- private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) {
- switch mode {
- case .off:
- m.tokenStatus = .new
- m.notificationMode = .off
- default:
- Task {
- do {
- let status = try await apiRegisterToken(token: token, notificationMode: mode)
- await MainActor.run {
- m.tokenStatus = status
- m.notificationMode = mode
- }
- } catch let error {
- let a = getErrorAlert(error, "Error enabling notifications")
- AlertManager.shared.showAlertMsg(
- title: a.title,
- message: a.message
- )
- }
- }
- }
- }
-
- private func infoText() -> some View {
- Button {
- showInfo = true
- } label: {
- Label("How it affects privacy", systemImage: "info.circle")
- .font(.headline)
- }
}
}
@@ -180,6 +135,6 @@ struct NotificationsInfoView: View {
struct NotificationsModeView_Previews: PreviewProvider {
static var previews: some View {
- SetNotificationsMode()
+ SetNotificationsMode(notificationMode: .constant(.instant))
}
}
diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
index 80f35c1190..15b8e05b5e 100644
--- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
+++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
@@ -12,68 +12,84 @@ import SimpleXChat
struct SimpleXInfo: View {
@EnvironmentObject var m: ChatModel
+ @EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme: ColorScheme
- @State private var showHowItWorks = false
+ @State private var showWhyBuilt = false
@State private var createProfileNavLinkActive = false
var onboarding: Bool
var body: some View {
GeometryReader { g in
- let v = ScrollView {
- VStack(alignment: .leading) {
- VStack(alignment: .center, spacing: 10) {
- Image(colorScheme == .light ? "logo" : "logo-light")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: g.size.width * 0.67)
- .padding(.bottom, 8)
- .padding(.leading, 4)
- .frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
-
- Button {
- showHowItWorks = true
- } label: {
- Label("The future of messaging", systemImage: "info.circle")
- .font(.headline)
- }
- }
+ VStack(alignment: .center, spacing: 10) {
+ Image(colorScheme == .light ? "logo" : "logo-light")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: (g.size.width - 50) * 0.55)
+ .padding(.leading, 4)
+ .frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
- Spacer()
+ #if SIMPLEX_ASSETS
+ Image(colorScheme == .light ? "intro" : "intro-light")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ #else
+ ZStack {
+ let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5)
+ LinearGradient(
+ stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops,
+ startPoint: gp.start,
+ endPoint: gp.end
+ )
+ Image(systemName: "bubble.left.and.bubble.right")
+ .font(.system(size: 72))
+ .foregroundColor(theme.colors.primary)
+ }
+ .aspectRatio(1.0, contentMode: .fit)
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+ .padding(.horizontal, 25)
+ .frame(maxWidth: .infinity)
+ #endif
- VStack(alignment: .leading) {
- onboardingInfoRow("privacy", "Privacy redefined",
- "No user identifiers.", width: 48)
- onboardingInfoRow("shield", "Immune to spam",
- "You decide who can connect.", width: 46)
- onboardingInfoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized",
- "Anybody can host servers.", width: 46)
- }
- .padding(.leading, 16)
+ Text("Be free\nin your network")
+ .font(.largeTitle)
+ .bold()
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
- Spacer()
+ Text("Private and secure messaging.")
+ .font(.title3)
+ .fontWeight(.medium)
+ .foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
- if onboarding {
- VStack(spacing: 10) {
- createFirstProfileButton()
+ Text("The first network where you own\nyour contacts and groups.")
+ .font(.footnote)
+ .foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
- Button {
- m.migrationState = .pasteOrScanLink
- } label: {
- Label("Migrate from another device", systemImage: "tray.and.arrow.down")
- .font(.system(size: 17, weight: .semibold))
- .frame(minHeight: 40)
- }
- .frame(maxWidth: .infinity)
- }
+ if onboarding {
+ Spacer(minLength: 0)
+
+ createFirstProfileButton()
+ .padding(.vertical, 10)
+
+ Button {
+ showWhyBuilt = true
+ } label: {
+ Label("Why SimpleX is built.", systemImage: "info.circle")
+ .font(.headline)
}
}
- .padding(.horizontal, 25)
- .padding(.top, 75)
- .padding(.bottom, 25)
- .frame(minHeight: g.size.height)
}
+ .padding(.horizontal, 25)
+ .padding(.top, 28)
+ .padding(.bottom, 20)
+ .frame(minHeight: g.size.height)
.sheet(isPresented: Binding(
- get: { m.migrationState != nil },
+ get: { m.migrationState != nil && !createProfileNavLinkActive },
set: { _ in
m.migrationState = nil
MigrationToDeviceState.save(nil) }
@@ -86,17 +102,12 @@ struct SimpleXInfo: View {
.modifier(ThemedBackground(grouped: true))
}
}
- .sheet(isPresented: $showHowItWorks) {
- HowItWorks(
+ .sheet(isPresented: $showWhyBuilt) {
+ WhySimpleX(
onboarding: onboarding,
createProfileNavLinkActive: $createProfileNavLinkActive
)
}
- if #available(iOS 16.4, *) {
- v.scrollBounceBehavior(.basedOnSize)
- } else {
- v
- }
}
.onAppear() {
setLastVersionDefault()
@@ -105,32 +116,12 @@ struct SimpleXInfo: View {
.navigationBarHidden(true) // necessary on iOS 15
}
- private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
- HStack(alignment: .top) {
- Image(image)
- .resizable()
- .scaledToFit()
- .frame(width: width, height: 54)
- .frame(width: 54)
- .padding(.trailing, 10)
- VStack(alignment: .leading, spacing: 4) {
- Text(title).font(.headline)
- Text(text).frame(minHeight: 40, alignment: .top)
- .font(.callout)
- .lineLimit(3)
- .fixedSize(horizontal: false, vertical: true)
- }
- .padding(.top, 4)
- }
- .padding(.bottom, 12)
- }
-
private func createFirstProfileButton() -> some View {
ZStack {
Button {
createProfileNavLinkActive = true
} label: {
- Text("Create your profile")
+ Text("Get started")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
index 8a7ab465d4..41a342d7c8 100644
--- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
+++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
@@ -632,6 +632,38 @@ private let versionDescriptions: [VersionDescription] = [
))
]
),
+ VersionDescription(
+ version: "v6.5",
+ post: URL(string: "https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html"),
+ features: [
+ .feature(Description(
+ icon: nil,
+ title: "Public channels - speak freely 🚀",
+ description: nil,
+ subfeatures: [
+ ("antenna.radiowaves.left.and.right", "Reliability: many relays per channel."),
+ ("server.rack", "Ownership: you can run your own relays."),
+ ("key.2.on.ring", "Security: owners hold channel keys."),
+ ("person.badge.shield.checkmark", "Privacy: for owners and subscribers."),
+ ]
+ )),
+ .feature(Description(
+ icon: "link.badge.plus",
+ title: "Easier to invite your friends 👋",
+ description: "We made connecting simpler for new users."
+ )),
+ .feature(Description(
+ icon: "network.badge.shield.half.filled",
+ title: "Safe web links",
+ description: "- opt-in to send link previews.\n- prevent hyperlink phishing.\n- remove link tracking."
+ )),
+ .feature(Description(
+ icon: "network",
+ title: "Non-profit governance",
+ description: "To make SimpleX Network last."
+ ))
+ ]
+ ),
]
private let lastVersion = versionDescriptions.last!.version
@@ -759,7 +791,7 @@ struct WhatsNewView: View {
}
}
if let post = v.post {
- Link(destination: post) {
+ ExternalLink(destination: post) {
HStack {
Text("Read more")
Image(systemName: "arrow.up.right.circle")
diff --git a/apps/ios/Shared/Views/Onboarding/YourNetwork.swift b/apps/ios/Shared/Views/Onboarding/YourNetwork.swift
new file mode 100644
index 0000000000..d3727e196e
--- /dev/null
+++ b/apps/ios/Shared/Views/Onboarding/YourNetwork.swift
@@ -0,0 +1,193 @@
+//
+// YourNetwork.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 22/04/2026.
+// Copyright © 2026 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+private enum YourNetworkSheet: Identifiable {
+ case configureOperators
+ case configureNotifications
+
+ var id: String {
+ switch self {
+ case .configureOperators: return "configureOperators"
+ case .configureNotifications: return "configureNotifications"
+ }
+ }
+}
+
+struct YourNetworkView: View {
+ @EnvironmentObject var theme: AppTheme
+ @Environment(\.colorScheme) var colorScheme: ColorScheme
+ @State private var serverOperators: [ServerOperator] = []
+ @State private var selectedOperatorIds = Set()
+ @State private var notificationMode: NotificationsMode = .instant
+ @State private var sheetItem: YourNetworkSheet? = nil
+ @State private var nextStepNavLinkActive = false
+ @State private var justOpened = true
+
+ var body: some View {
+ GeometryReader { g in
+ VStack(alignment: .center, spacing: 10) {
+ Spacer(minLength: 0)
+
+ #if SIMPLEX_ASSETS
+ Image(colorScheme == .light ? "your-network" : "your-network-light")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ #else
+ ZStack {
+ let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5)
+ LinearGradient(
+ stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops,
+ startPoint: gp.start,
+ endPoint: gp.end
+ )
+ Image(systemName: "network")
+ .font(.system(size: 72))
+ .foregroundColor(theme.colors.primary)
+ }
+ .aspectRatio(1.0, contentMode: .fit)
+ .clipShape(RoundedRectangle(cornerRadius: 24))
+ .padding(.horizontal, 25)
+ .frame(maxWidth: .infinity)
+ #endif
+
+ Text("Your network")
+ .font(.largeTitle)
+ .bold()
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 15)
+
+ Text("Network routers cannot know\nwho talks to whom")
+ .font(.title3)
+ .fontWeight(.medium)
+ .foregroundColor(theme.colors.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+
+ VStack(alignment: .leading, spacing: 20) {
+ configureRoutersButton()
+ configureNotificationsButton()
+ }
+ .padding(.top, 15)
+ .padding(.bottom, 15)
+
+ Spacer(minLength: 0)
+
+ continueButton()
+ .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
+ }
+ .padding(.horizontal, 25)
+ .padding(.top, 8)
+ .padding(.bottom, 20)
+ .frame(minHeight: g.size.height)
+ }
+ .onAppear {
+ if justOpened {
+ serverOperators = ChatModel.shared.conditions.serverOperators
+ selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
+ justOpened = false
+ }
+ }
+ .sheet(item: $sheetItem) { item in
+ switch item {
+ case .configureOperators:
+ ChooseServerOperators(serverOperators: serverOperators, selectedOperatorIds: $selectedOperatorIds)
+ .modifier(ThemedBackground())
+ case .configureNotifications:
+ SetNotificationsMode(notificationMode: $notificationMode)
+ .modifier(ThemedBackground())
+ }
+ }
+ .frame(maxHeight: .infinity)
+ .navigationBarHidden(true)
+ }
+
+ private func configureRoutersButton() -> some View {
+ Button {
+ sheetItem = .configureOperators
+ } label: {
+ HStack(spacing: 6) {
+ Text("Setup routers")
+ .fontWeight(.medium)
+ ForEach(serverOperators.reversed()) { op in
+ Image(op.logo(colorScheme))
+ .resizable()
+ .scaledToFit()
+ .frame(width: 22, height: 22)
+ .grayscale(selectedOperatorIds.contains(op.operatorId) ? 0.0 : 1.0)
+ }
+ }
+ }
+ }
+
+ private func configureNotificationsButton() -> some View {
+ Button {
+ sheetItem = .configureNotifications
+ } label: {
+ HStack(spacing: 4) {
+ Text("Setup notifications")
+ .fontWeight(.medium)
+ Image(systemName: notificationMode.icon)
+ }
+ }
+ }
+
+ private func continueButton() -> some View {
+ ZStack {
+ Button {
+ applyNotificationMode()
+ onboardingStageDefault.set(.step4_NetworkCommitments)
+ nextStepNavLinkActive = true
+ } label: {
+ Text("Continue")
+ }
+ .buttonStyle(OnboardingButtonStyle())
+
+ NavigationLink(isActive: $nextStepNavLinkActive) {
+ OnboardingConditionsView(selectedOperatorIds: selectedOperatorIds)
+ .navigationBarBackButtonHidden(true)
+ .modifier(ThemedBackground())
+ } label: {
+ EmptyView()
+ }
+ .frame(width: 1, height: 1)
+ .hidden()
+ }
+ }
+
+ private func applyNotificationMode() {
+ let m = ChatModel.shared
+ if let token = m.deviceToken {
+ switch notificationMode {
+ case .off:
+ m.tokenStatus = .new
+ m.notificationMode = .off
+ default:
+ Task {
+ do {
+ let status = try await apiRegisterToken(token: token, notificationMode: notificationMode)
+ await MainActor.run {
+ m.tokenStatus = status
+ m.notificationMode = notificationMode
+ }
+ } catch let error {
+ let a = getErrorAlert(error, "Error enabling notifications")
+ AlertManager.shared.showAlertMsg(
+ title: a.title,
+ message: a.message
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift
index 44e0b20958..8be0798fb1 100644
--- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift
@@ -76,7 +76,7 @@ extension AppSettings {
c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get()
c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get()
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
- c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
+ c.privacyLinkPreviews = privacyLinkPreviewsGroupDefault.get()
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
index 6df2d5422e..a504b00116 100644
--- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
+++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift
@@ -22,14 +22,16 @@ struct DeveloperView: View {
VStack {
List {
Section {
- ZStack(alignment: .leading) {
- Image(colorScheme == .dark ? "github_light" : "github")
- .resizable()
- .frame(width: 24, height: 24)
- .opacity(0.5)
- .colorMultiply(theme.colors.secondary)
- Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)")
- .padding(.leading, 36)
+ ExternalLink(destination: URL(string: "https://github.com/simplex-chat/simplex-chat")!) {
+ ZStack(alignment: .leading) {
+ Image(colorScheme == .dark ? "github_light" : "github")
+ .resizable()
+ .frame(width: 24, height: 24)
+ .opacity(0.5)
+ .colorMultiply(theme.colors.secondary)
+ Text("Install SimpleX Chat for terminal")
+ .padding(.leading, 36)
+ }
}
NavigationLink {
TerminalView()
@@ -91,6 +93,11 @@ struct DeveloperView: View {
UserDefaults.standard.set(val, forKey: def)
}
}
+ for def in hintGroupDefaults {
+ if let val = groupAppDefaults[def] as? Bool {
+ groupDefaults.set(val, forKey: def)
+ }
+ }
hintsUnchanged = true
}
}
@@ -98,6 +105,8 @@ struct DeveloperView: View {
private func hintDefaultsUnchanged() -> Bool {
hintDefaults.allSatisfy { def in
appDefaults[def] as? Bool == UserDefaults.standard.bool(forKey: def)
+ } && hintGroupDefaults.allSatisfy { def in
+ groupAppDefaults[def] as? Bool == groupDefaults.bool(forKey: def)
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift
index d9862aaac8..f74516c2c8 100644
--- a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift
+++ b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift
@@ -23,7 +23,7 @@ struct IncognitoHelp: View {
Text("Incognito mode protects your privacy by using a new random profile for each contact.")
Text("It allows having many anonymous connections without any shared data between them in a single chat profile.")
Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.")
- Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).")
+ ExternalLink("Read more in User Guide.", destination: URL(string: "https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode")!)
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift
index 6f76e69182..5abbbf8d2e 100644
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift
@@ -71,11 +71,7 @@ struct ConditionsWebView: UIViewRepresentable {
switch navigationAction.navigationType {
case .linkActivated:
decisionHandler(.cancel)
- if url.absoluteString.starts(with: "https://simplex.chat/contact#") {
- ChatModel.shared.appOpenUrl = url
- } else {
- UIApplication.shared.open(url)
- }
+ openExternalLink(url)
default:
decisionHandler(.allow)
}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift
index 74b7374654..f10b945dc0 100644
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift
@@ -332,7 +332,7 @@ struct UsageConditionsView: View {
@ViewBuilder private func conditionsDiffButton(_ font: Font? = nil) -> some View {
let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit
if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") {
- Link(destination: commitUrl) {
+ ExternalLink(destination: commitUrl) {
HStack {
Text("Open changes")
Image(systemName: "arrow.up.right.circle")
@@ -351,21 +351,6 @@ private func regularConditionsHeader() -> some View {
}
}
-struct SimpleConditionsView: View {
-
- var body: some View {
- VStack(alignment: .leading, spacing: 20) {
- regularConditionsHeader()
- .padding(.top)
- .padding(.top)
- ConditionsTextView()
- .padding(.bottom)
- .padding(.bottom)
- }
- .padding(.horizontal, 25)
- .frame(maxHeight: .infinity)
- }
-}
func validateServers_(
_ userServers: Binding<[UserOperatorServers]>,
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift
index 9d068d3b26..26f24f2f0f 100644
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift
@@ -364,11 +364,15 @@ struct OperatorInfoView: View {
Text(d)
}
}
- Link(serverOperator.info.website.absoluteString, destination: serverOperator.info.website)
+ ExternalLink(destination: serverOperator.info.website) {
+ Text(serverOperator.info.website.absoluteString)
+ }
}
if let selfhost = serverOperator.info.selfhost {
Section {
- Link(selfhost.text, destination: selfhost.link)
+ ExternalLink(destination: selfhost.link) {
+ Text(selfhost.text)
+ }
}
}
}
@@ -432,7 +436,7 @@ struct ConditionsTextView: View {
private func conditionsLinkView(_ conditionsLink: String) -> some View {
VStack(alignment: .leading, spacing: 20) {
Text("Current conditions text couldn't be loaded, you can review conditions via this link:")
- Link(destination: URL(string: conditionsLink)!) {
+ ExternalLink(destination: URL(string: conditionsLink)!) {
Text(conditionsLink)
.multilineTextAlignment(.leading)
}
@@ -591,11 +595,11 @@ func conditionsLinkButton() -> some View {
let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit
let mdUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/\(commit)/PRIVACY.md") ?? conditionsURL
return Menu {
- Link(destination: mdUrl) {
+ ExternalLink(destination: mdUrl) {
Label("Open conditions", systemImage: "doc")
}
if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") {
- Link(destination: commitUrl) {
+ ExternalLink(destination: commitUrl) {
Label("Open changes", systemImage: "ellipsis")
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift
index e57df4c5dc..b059be7cb0 100644
--- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift
+++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift
@@ -223,9 +223,7 @@ struct YourServersView: View {
func howToButton() -> some View {
Button {
- DispatchQueue.main.async {
- UIApplication.shared.open(howToUrl)
- }
+ openExternalLink(howToUrl)
} label: {
HStack {
Text("How to use your servers")
diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
index eec820833c..3ae9f0eacd 100644
--- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
@@ -13,7 +13,7 @@ struct PrivacySettings: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
- @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
+ @AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS, store: groupDefaults) private var useLinkPreviews = true
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@@ -74,8 +74,8 @@ struct PrivacySettings: View {
settingsRow("network", color: theme.colors.secondary) {
Toggle("Send link previews", isOn: $useLinkPreviews)
.onChange(of: useLinkPreviews) { linkPreviews in
- privacyLinkPreviewsGroupDefault.set(linkPreviews)
- privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5
+ UserDefaults.standard.set(linkPreviews, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
+ privacyLinkPreviewsShowAlertGroupDefault.set(false)
}
}
settingsRow("link", color: theme.colors.secondary) {
diff --git a/apps/ios/Shared/Views/UserSettings/RTCServers.swift b/apps/ios/Shared/Views/UserSettings/RTCServers.swift
index ef891738cc..b045a8ce55 100644
--- a/apps/ios/Shared/Views/UserSettings/RTCServers.swift
+++ b/apps/ios/Shared/Views/UserSettings/RTCServers.swift
@@ -139,9 +139,7 @@ struct RTCServers: View {
func howToButton() -> some View {
Button {
- DispatchQueue.main.async {
- UIApplication.shared.open(howToUrl)
- }
+ openExternalLink(howToUrl)
} label: {
HStack{
Text("How to")
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index c091224098..a903329454 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -11,7 +11,7 @@ import SwiftUI
import StoreKit
import SimpleXChat
-let simplexTeamURL = URL(string: "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")!
+let simplexTeamURL = URL(string: "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im")!
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
@@ -150,6 +150,10 @@ let hintDefaults = [
DEFAULT_SHOW_DELETE_CONTACT_NOTICE
]
+let hintGroupDefaults = [
+ GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT
+]
+
// not used anymore
enum ConnectViaLinkTab: String {
case scan
@@ -395,7 +399,9 @@ struct SettingsView: View {
}
Section(header: Text("Support SimpleX Chat").foregroundColor(theme.colors.secondary)) {
- settingsRow("keyboard", color: theme.colors.secondary) { Text("[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)") }
+ settingsRow("keyboard", color: theme.colors.secondary) {
+ ExternalLink("Contribute", destination: URL(string: "https://github.com/simplex-chat/simplex-chat#contribute")!)
+ }
settingsRow("star", color: theme.colors.secondary) {
Button("Rate the app") {
if let scene = sceneDelegate.windowScene {
@@ -403,14 +409,16 @@ struct SettingsView: View {
}
}
}
- ZStack(alignment: .leading) {
- Image(colorScheme == .dark ? "github_light" : "github")
- .resizable()
- .frame(width: 24, height: 24)
- .opacity(0.5)
- .colorMultiply(theme.colors.secondary)
- Text("[Star on GitHub](https://github.com/simplex-chat/simplex-chat)")
- .padding(.leading, indent)
+ ExternalLink(destination: URL(string: "https://github.com/simplex-chat/simplex-chat")!) {
+ ZStack(alignment: .leading) {
+ Image(colorScheme == .dark ? "github_light" : "github")
+ .resizable()
+ .frame(width: 24, height: 24)
+ .opacity(0.5)
+ .colorMultiply(theme.colors.secondary)
+ Text("Star on GitHub")
+ .padding(.leading, indent)
+ }
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift b/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift
index 6c1ea8deb2..ac6ae05984 100644
--- a/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift
@@ -31,7 +31,7 @@ struct UserAddressLearnMore: View {
.padding(.top)
Text("SimpleX address and 1-time links are safe to share via any messenger.")
Text("To protect against your link being replaced, you can compare contact security codes.")
- Text("Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).")
+ ExternalLink("Read more in User Guide.", destination: URL(string: "https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses")!)
.padding(.top)
}
diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
index d40ec116f4..e22042fa24 100644
--- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
@@ -11,11 +11,13 @@ import MessageUI
@preconcurrency import SimpleXChat
struct UserAddressView: View {
+ @Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State var shareViaProfile = false
@State var autoCreate = false
+ var onboarding: Bool = false
@State private var showShortLink = true
@State private var settings = AddressSettingsState()
@State private var savedSettings = AddressSettingsState()
@@ -54,6 +56,14 @@ struct UserAddressView: View {
}
}
}
+ .if(onboarding) { v in
+ v.toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Image(systemName: "info.circle").opacity(0)
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ }
.onAppear {
if chatModel.userAddress == nil, autoCreate {
createAddress()
@@ -64,12 +74,16 @@ struct UserAddressView: View {
private func userAddressView() -> some View {
List {
if let userAddress = chatModel.userAddress {
- existingAddressView(userAddress)
- .onAppear {
- settings = AddressSettingsState(settings: userAddress.addressSettings)
- savedSettings = AddressSettingsState(settings: userAddress.addressSettings)
- }
- } else {
+ if onboarding {
+ onboardingAddressView(userAddress)
+ } else {
+ existingAddressView(userAddress)
+ .onAppear {
+ settings = AddressSettingsState(settings: userAddress.addressSettings)
+ savedSettings = AddressSettingsState(settings: userAddress.addressSettings)
+ }
+ }
+ } else if !onboarding {
Section {
createAddressButton()
} header: {
@@ -121,8 +135,8 @@ struct UserAddressView: View {
)
case .shareOnCreate:
return Alert(
- title: Text("Share address with contacts?"),
- message: Text("Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts."),
+ title: Text("Share address with SimpleX contacts?"),
+ message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."),
primaryButton: .default(Text("Share")) {
setProfileAddress($progressIndicator, true)
shareViaProfile = true
@@ -157,7 +171,19 @@ struct UserAddressView: View {
}
addressSettingsButton(userAddress)
} header: {
+ #if SIMPLEX_ASSETS
+ VStack(alignment: .leading, spacing: 0) {
+ Image(colorScheme == .light ? "simplex-address-small" : "simplex-address-small-light")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ .padding(.top, -20)
+ ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink)
+ }
+ .padding(.bottom, 4)
+ #else
ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink)
+ #endif
} footer: {
if settings.businessAddress {
Text("Add your team members to the conversations.")
@@ -184,6 +210,54 @@ struct UserAddressView: View {
}
}
+ @ViewBuilder private func onboardingAddressView(_ userAddress: UserContactLink) -> some View {
+ Section {
+ HStack(spacing: 8) {
+ let link = userAddress.connLinkContact.simplexChatUri(short: showShortLink)
+ linkTextView(link)
+ Button { showShareSheet(items: [link]) } label: {
+ Image(systemName: "square.and.arrow.up")
+ .padding(.top, -7)
+ .padding(.horizontal, 8)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ } header: {
+ #if SIMPLEX_ASSETS
+ VStack(alignment: .leading) {
+ Image(colorScheme == .light ? "simplex-address" : "simplex-address-light")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ Text("Use this address in your social media profile, website, or email signature.")
+ .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil)
+ }
+ .padding(.bottom, 4)
+ #else
+ Text("Use this address in your social media profile, website, or email signature.")
+ .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil)
+ .padding(.bottom, 6)
+ #endif
+ }
+ .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
+
+ Section {
+ SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink)
+ .id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))")
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color(uiColor: .secondarySystemGroupedBackground))
+ )
+ .padding(.horizontal)
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
+ } header: {
+ Text("Or use this QR - print or show online.").font(.body).foregroundColor(theme.colors.onBackground).textCase(nil)
+ }
+ }
+
private func createAddressButton() -> some View {
Button {
createAddress()
@@ -200,9 +274,22 @@ struct UserAddressView: View {
DispatchQueue.main.async {
if let connLinkContact {
chatModel.userAddress = UserContactLink(connLinkContact)
- alert = .shareOnCreate
+ let hasRelevantContacts = chatModel.chats.contains { chat in
+ if case let .direct(contact) = chat.chatInfo {
+ return contact.active && !contact.isContactCard && !contact.contactConnIncognito
+ }
+ return false
+ }
+ if hasRelevantContacts {
+ alert = .shareOnCreate
+ progressIndicator = false
+ } else {
+ setProfileAddress($progressIndicator, true)
+ shareViaProfile = true
+ }
+ } else {
+ progressIndicator = false
}
- progressIndicator = false
}
} catch let error {
logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
@@ -486,15 +573,15 @@ struct UserAddressSettingsView: View {
private func shareWithContactsButton() -> some View {
settingsRow("person", color: theme.colors.secondary) {
- Toggle("Share with contacts", isOn: $shareViaProfile)
+ Toggle("Share with SimpleX contacts", isOn: $shareViaProfile)
.onChange(of: shareViaProfile) { on in
if ignoreShareViaProfileChange {
ignoreShareViaProfileChange = false
} else {
if on {
showAlert(
- NSLocalizedString("Share address with contacts?", comment: "alert title"),
- message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"),
+ NSLocalizedString("Share address with SimpleX contacts?", comment: "alert title"),
+ message: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert message"),
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
@@ -516,7 +603,7 @@ struct UserAddressSettingsView: View {
} else {
showAlert(
NSLocalizedString("Stop sharing address?", comment: "alert title"),
- message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"),
+ message: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert message"),
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
index 569b5caf13..2e609c3f7d 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
@@ -26,7 +26,7 @@ struct UserProfile: View {
var body: some View {
List {
- EditProfileImage(profileImage: $profile.image, showChooseSource: $showChooseSource)
+ EditProfileImage(profileImage: $profile.image, iconName: "person.crop.circle.fill", showChooseSource: $showChooseSource)
.padding(.top)
Section {
@@ -178,6 +178,7 @@ struct EditProfileImage: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
@Binding var profileImage: String?
+ var iconName: String
@Binding var showChooseSource: Bool
var body: some View {
@@ -193,7 +194,7 @@ struct EditProfileImage: View {
}
} else {
ZStack(alignment: .center) {
- ProfileImage(imageStr: profileImage, size: 160)
+ ProfileImage(imageStr: profileImage, iconName: iconName, size: 160)
editImageButton { showChooseSource = true }
}
}
diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff
index d1bb5fe716..71a7a427be 100644
--- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff
+++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff
@@ -185,6 +185,21 @@
%d месеца
time interval
+
+ %d relays failed
+ channel relay bar
+channel subscriber relay bar
+
+
+ %d relays not active
+ channel relay bar
+channel subscriber relay bar
+
+
+ %d relays removed
+ channel relay bar
+channel subscriber relay bar
+
%d sec
%d сек.
@@ -200,11 +215,53 @@
%d пропуснато(и) съобщение(я)
integrity error chat item
+
+ %d subscriber
+ channel subscriber count
+
+
+ %d subscribers
+ channel subscriber count
+
%d weeks
%d седмици
time interval
+
+ %1$d/%2$d relays active
+ channel creation progress
+channel relay bar progress
+
+
+ %1$d/%2$d relays active, %3$d errors
+ channel relay bar
+
+
+ %1$d/%2$d relays active, %3$d failed
+ channel creation progress with errors
+channel relay bar
+
+
+ %1$d/%2$d relays active, %3$d removed
+ channel relay bar
+
+
+ %1$d/%2$d relays connected
+ channel subscriber relay bar progress
+
+
+ %1$d/%2$d relays connected, %3$d errors
+ channel subscriber relay bar
+
+
+ %1$d/%2$d relays connected, %3$d failed
+ channel subscriber relay bar
+
+
+ %1$d/%2$d relays connected, %3$d removed
+ channel subscriber relay bar
+
%lld
%lld
@@ -215,6 +272,10 @@
%lld %@
No comment provided by engineer.
+
+ %lld channel events
+ No comment provided by engineer.
+
%lld contact(s) selected
%lld избран(и) контакт(а)
@@ -315,11 +376,19 @@
%u пропуснати съобщения.
No comment provided by engineer.
+
+ (from owner)
+ chat link info line
+
(new)
(ново)
No comment provided by engineer.
+
+ (signed)
+ chat link info line
+
(this device v%@)
(това устройство v%@)
@@ -365,6 +434,10 @@
**Сканирай / Постави линк**: за свързване чрез получения линк.
No comment provided by engineer.
+
+ **Test relay** to retrieve its name.
+ No comment provided by engineer.
+
**Warning**: Instant push notifications require passphrase saved in Keychain.
**Внимание**: Незабавните push известия изискват парола, запазена в Keychain.
@@ -408,6 +481,12 @@
- и още!
No comment provided by engineer.
+
+ - opt-in to send link previews.
+- prevent hyperlink phishing.
+- remove link tracking.
+ No comment provided by engineer.
+
- optionally notify deleted contacts.
- profile names with spaces.
@@ -506,6 +585,10 @@ time interval
Още няколко неща
No comment provided by engineer.
+
+ A link for one person to connect
+ No comment provided by engineer.
+
A new contact
Нов контакт
@@ -632,9 +715,8 @@ swipe action
Активни връзки
No comment provided by engineer.
-
- Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts.
- Добавете адрес към вашия профил, така че вашите контакти да могат да го споделят с други хора. Актуализацията на профила ще бъде изпратена до вашите контакти.
+
+ Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts.
No comment provided by engineer.
@@ -702,6 +784,10 @@ swipe action
Добавени сървъри за съобщения
No comment provided by engineer.
+
+ Adding relays will be supported later.
+ No comment provided by engineer.
+
Additional accent
Допълнителен акцент
@@ -821,6 +907,14 @@ swipe action
Всички профили
profile dropdown
+
+ All relays failed
+ No comment provided by engineer.
+
+
+ All relays removed
+ No comment provided by engineer.
+
All reports will be archived for you.
Всички доклади за нарушения ще бъдат архивирани за вас.
@@ -881,6 +975,10 @@ swipe action
Позволи необратимо изтриване на съобщение само ако вашият контакт го рарешава. (24 часа)
No comment provided by engineer.
+
+ Allow members to chat with admins.
+ No comment provided by engineer.
+
Allow message reactions only if your contact allows them.
Позволи реакции на съобщения само ако вашият контакт ги разрешава.
@@ -896,6 +994,10 @@ swipe action
Позволи изпращането на лични съобщения до членовете.
No comment provided by engineer.
+
+ Allow sending direct messages to subscribers.
+ No comment provided by engineer.
+
Allow sending disappearing messages.
Разреши изпращането на изчезващи съобщения.
@@ -906,6 +1008,10 @@ swipe action
Позволи споделяне
No comment provided by engineer.
+
+ Allow subscribers to chat with admins.
+ No comment provided by engineer.
+
Allow to irreversibly delete sent messages. (24 hours)
Позволи необратимо изтриване на изпратените съобщения. (24 часа)
@@ -1011,11 +1117,6 @@ swipe action
Отговор на повикване
No comment provided by engineer.
-
- Anybody can host servers.
- Протокол и код с отворен код – всеки може да оперира собствени сървъри.
- No comment provided by engineer.
-
App build: %@
Компилация на приложението: %@
@@ -1220,6 +1321,19 @@ swipe action
Лош хеш на съобщението
No comment provided by engineer.
+
+ Be free
+in your network
+ No comment provided by engineer.
+
+
+ Be free in your network.
+ No comment provided by engineer.
+
+
+ Because we destroyed the power to know who you are. So that your power can never be taken.
+ No comment provided by engineer.
+
Better calls
По-добри обаждания
@@ -1315,6 +1429,10 @@ swipe action
Блокирай члена?
No comment provided by engineer.
+
+ Block subscriber for all?
+ No comment provided by engineer.
+
Blocked by admin
Блокиран от админ
@@ -1365,6 +1483,14 @@ swipe action
И вие, и вашият контакт можете да изпращате гласови съобщения.
No comment provided by engineer.
+
+ Bottom bar
+ No comment provided by engineer.
+
+
+ Broadcast
+ compose placeholder for channel owner
+
Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!
Български, финландски, тайландски и украински - благодарение на потребителите и [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!
@@ -1373,7 +1499,7 @@ swipe action
Business address
Бизнес адрес
- No comment provided by engineer.
+ chat link info line
Business chats
@@ -1395,15 +1521,6 @@ swipe action
Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА).
No comment provided by engineer.
-
- By using SimpleX Chat you agree to:
-- send only legal content in public groups.
-- respect other users – no spam.
- С използването на SimpleX Chat вие се съгласявате със:
-- изпращане само на легално съдържание в публични групи.
-- уважение към другите потребители – без спам.
- No comment provided by engineer.
-
Call already ended!
Разговорът вече приключи!
@@ -1552,6 +1669,67 @@ new chat action
authentication reason
set passcode view
+
+ Channel
+ No comment provided by engineer.
+
+
+ Channel display name
+ No comment provided by engineer.
+
+
+ Channel full name (optional)
+ No comment provided by engineer.
+
+
+ Channel has no active relays. Please try to join later.
+ alert message
+alert subtitle
+
+
+ Channel image
+ No comment provided by engineer.
+
+
+ Channel link
+ chat link info line
+
+
+ Channel preferences
+ No comment provided by engineer.
+
+
+ Channel profile
+ No comment provided by engineer.
+
+
+ Channel profile is stored on subscribers' devices and on the chat relays.
+ No comment provided by engineer.
+
+
+ Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers.
+ alert message
+
+
+ Channel temporarily unavailable
+ alert title
+
+
+ Channel will be deleted for all subscribers - this cannot be undone!
+ No comment provided by engineer.
+
+
+ Channel will be deleted for you - this cannot be undone!
+ No comment provided by engineer.
+
+
+ Channel will start working with %1$d of %2$d relays. Proceed?
+ alert message
+
+
+ Channels
+ No comment provided by engineer.
+
Chat
Чат
@@ -1637,6 +1815,22 @@ set passcode view
Потребителски профил
No comment provided by engineer.
+
+ Chat relay
+ No comment provided by engineer.
+
+
+ Chat relays
+ No comment provided by engineer.
+
+
+ Chat relays forward messages in channels you create.
+ No comment provided by engineer.
+
+
+ Chat relays forward messages to channel subscribers.
+ No comment provided by engineer.
+
Chat theme
Тема на чата
@@ -1655,7 +1849,8 @@ set passcode view
Chat with admins
Чат с администраторите
- chat toolbar
+ chat feature
+chat toolbar
Chat with member
@@ -1672,11 +1867,23 @@ set passcode view
Чатове
No comment provided by engineer.
+
+ Chats with admins are prohibited.
+ No comment provided by engineer.
+
+
+ Chats with admins in public channels have no E2E encryption - use only with trusted chat relays.
+ alert message
+
Chats with members
Чатове с членовете
No comment provided by engineer.
+
+ Chats with members are disabled
+ No comment provided by engineer.
+
Check messages every 20 min.
Проверявай за съобщенията на всеки 20 минути.
@@ -1687,6 +1894,14 @@ set passcode view
Проверявай за съобщенията, когато е разрешено.
No comment provided by engineer.
+
+ Check relay address and try again.
+ alert message
+
+
+ Check relay name and try again.
+ alert message
+
Check server address and try again.
Проверете адреса на сървъра и опитайте отново.
@@ -1832,9 +2047,8 @@ set passcode view
Конфигурирай ICE сървъри
No comment provided by engineer.
-
- Configure server operators
- Конфигуриране на сървърни оператори
+
+ Configure relays
No comment provided by engineer.
@@ -1895,7 +2109,8 @@ set passcode view
Connect
Свързване
- server test step
+ relay test step
+server test step
Connect automatically
@@ -1941,6 +2156,10 @@ This is your own one-time link!
Свърване чрез линк
new chat sheet title
+
+ Connect via link or QR code
+ No comment provided by engineer.
+
Connect via one-time link
Свързване чрез еднократен линк за връзка
@@ -2019,7 +2238,7 @@ This is your own one-time link!
Connection error (AUTH)
Грешка при свързване (AUTH)
- No comment provided by engineer.
+ conn error description
Connection failed
@@ -2074,6 +2293,10 @@ This is your own one-time link!
Connections
No comment provided by engineer.
+
+ Contact address
+ chat link info line
+
Contact allows
Контактът позволява
@@ -2139,6 +2362,11 @@ This is your own one-time link!
Продължи
No comment provided by engineer.
+
+ Contribute
+ Допринеси
+ No comment provided by engineer.
+
Conversation deleted!
No comment provided by engineer.
@@ -2164,12 +2392,7 @@ This is your own one-time link!
Correct name to %@?
Поправи име на %@?
- No comment provided by engineer.
-
-
- Create
- Създаване
- No comment provided by engineer.
+ alert message
Create 1-time link
@@ -2220,6 +2443,14 @@ This is your own one-time link!
Създай профил
No comment provided by engineer.
+
+ Create public channel
+ No comment provided by engineer.
+
+
+ Create public channel (BETA)
+ No comment provided by engineer.
+
Create queue
Създай опашка
@@ -2229,11 +2460,19 @@ This is your own one-time link!
Create your address
No comment provided by engineer.
+
+ Create your link
+ No comment provided by engineer.
+
Create your profile
Създай своя профил
No comment provided by engineer.
+
+ Create your public address
+ No comment provided by engineer.
+
Created
No comment provided by engineer.
@@ -2253,6 +2492,10 @@ This is your own one-time link!
Създаване на архивен линк
No comment provided by engineer.
+
+ Creating channel
+ No comment provided by engineer.
+
Creating link…
Линкът се създава…
@@ -2405,10 +2648,9 @@ This is your own one-time link!
Debug delivery
No comment provided by engineer.
-
- Decentralized
- Децентрализиран
- No comment provided by engineer.
+
+ Decode link
+ relay test step
Decryption error
@@ -2455,6 +2697,14 @@ swipe action
Изтрий и уведоми контакт
No comment provided by engineer.
+
+ Delete channel
+ No comment provided by engineer.
+
+
+ Delete channel?
+ No comment provided by engineer.
+
Delete chat
No comment provided by engineer.
@@ -2617,6 +2867,10 @@ alert button
Изтрий опашка
server test step
+
+ Delete relay
+ No comment provided by engineer.
+
Delete report
No comment provided by engineer.
@@ -2767,6 +3021,14 @@ alert button
Личните съобщения между членовете са забранени в тази група.
No comment provided by engineer.
+
+ Direct messages between subscribers are prohibited.
+ No comment provided by engineer.
+
+
+ Disable
+ alert button
+
Disable (keep overrides)
Деактивиране (запазване на промените)
@@ -2867,6 +3129,10 @@ alert button
Не изпращай история на нови членове.
No comment provided by engineer.
+
+ Do not send history to new subscribers.
+ No comment provided by engineer.
+
Do not use credentials with proxy.
No comment provided by engineer.
@@ -2959,11 +3225,19 @@ chat item action
E2E encrypted notifications.
No comment provided by engineer.
+
+ Easier to invite your friends 👋
+ No comment provided by engineer.
+
Edit
Редактирай
chat item action
+
+ Edit channel profile
+ No comment provided by engineer.
+
Edit group profile
Редактирай групов профил
@@ -2976,7 +3250,7 @@ chat item action
Enable
Активирай
- No comment provided by engineer.
+ alert button
Enable (keep overrides)
@@ -2997,6 +3271,10 @@ chat item action
Активирай TCP keep-alive
No comment provided by engineer.
+
+ Enable at least one chat relay in Network & Servers.
+ channel creation warning
+
Enable automatic message deletion?
Активиране на автоматично изтриване на съобщения?
@@ -3007,6 +3285,10 @@ chat item action
Разреши достъпа до камерата
No comment provided by engineer.
+
+ Enable chats with admins?
+ alert title
+
Enable disappearing messages by default.
No comment provided by engineer.
@@ -3026,16 +3308,15 @@ chat item action
Активирай незабавни известия?
No comment provided by engineer.
+
+ Enable link previews?
+ alert title
+
Enable lock
Активирай заключване
No comment provided by engineer.
-
- Enable notifications
- Активирай известията
- No comment provided by engineer.
-
Enable periodic notifications?
Активирай периодични известия?
@@ -3139,6 +3420,10 @@ chat item action
Въведете kодa за достъп
No comment provided by engineer.
+
+ Enter channel name…
+ No comment provided by engineer.
+
Enter correct passphrase.
Въведи правилна парола.
@@ -3164,6 +3449,14 @@ chat item action
Въведете парола по-горе, за да се покаже!
No comment provided by engineer.
+
+ Enter profile name...
+ No comment provided by engineer.
+
+
+ Enter relay name…
+ No comment provided by engineer.
+
Enter server manually
Въведи сървъра ръчно
@@ -3192,7 +3485,7 @@ chat item action
Error
Грешка при свързване със сървъра
- No comment provided by engineer.
+ conn error description
Error aborting address change
@@ -3217,6 +3510,10 @@ chat item action
Грешка при добавяне на член(ове)
No comment provided by engineer.
+
+ Error adding relay
+ alert title
+
Error adding server
alert title
@@ -3269,6 +3566,10 @@ chat item action
Грешка при създаване на адрес
No comment provided by engineer.
+
+ Error creating channel
+ alert title
+
Error creating group
Грешка при създаване на група
@@ -3398,10 +3699,6 @@ chat item action
Грешка при отваряне на чата
No comment provided by engineer.
-
- Error opening group
- No comment provided by engineer.
-
Error receiving file
Грешка при получаване на файл
@@ -3441,6 +3738,10 @@ chat item action
Грешка при запазване на ICE сървърите
No comment provided by engineer.
+
+ Error saving channel profile
+ No comment provided by engineer.
+
Error saving chat list
alert title
@@ -3503,6 +3804,10 @@ chat item action
Грешка при настройването на потвърждениeто за доставка!!
No comment provided by engineer.
+
+ Error sharing channel
+ alert title
+
Error starting chat
Грешка при стартиране на чата
@@ -3579,7 +3884,8 @@ snd error text
Error: %@.
- server test error
+ relay test error
+server test error
Error: URL is invalid
@@ -3802,7 +4108,8 @@ snd error text
Fingerprint in server address does not match certificate.
Въжможно е пръстовият отпечатък на сертификата в адреса на сървъра да е неправилен
- server test error
+ relay test error
+server test error
Fingerprint in server address does not match certificate: %@.
@@ -3842,9 +4149,14 @@ snd error text
For all moderators
No comment provided by engineer.
+
+ For anyone to reach you
+ No comment provided by engineer.
+
For chat profile %@:
- servers error
+ servers error
+servers warning
For console
@@ -3969,10 +4281,18 @@ Error: %2$@
GIF файлове и стикери
No comment provided by engineer.
+
+ Get link
+ relay test step
+
Get notified when mentioned.
No comment provided by engineer.
+
+ Get started
+ No comment provided by engineer.
+
Good afternoon!
message preview
@@ -4029,7 +4349,7 @@ Error: %2$@
Group link
Групов линк
- No comment provided by engineer.
+ chat link info line
Group links
@@ -4138,6 +4458,10 @@ Error: %2$@
Историята не се изпраща на нови членове.
No comment provided by engineer.
+
+ History is not sent to new subscribers.
+ No comment provided by engineer.
+
How SimpleX works
Как работи SimpleX
@@ -4232,11 +4556,6 @@ Error: %2$@
Веднага
No comment provided by engineer.
-
- Immune to spam
- Защитен от спам и злоупотреби
- No comment provided by engineer.
-
Import
Импортиране
@@ -4374,9 +4693,9 @@ More improvements are coming soon!
Първоначална роля
No comment provided by engineer.
-
- Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)
- Инсталирайте [SimpleX Chat за терминал](https://github.com/simplex-chat/simplex-chat)
+
+ Install SimpleX Chat for terminal
+ Инсталирайте SimpleX Chat за терминал
No comment provided by engineer.
@@ -4428,7 +4747,7 @@ More improvements are coming soon!
Invalid connection link
Невалиден линк за връзка
- No comment provided by engineer.
+ conn error description
Invalid display name!
@@ -4448,7 +4767,15 @@ More improvements are coming soon!
Invalid name!
Невалидно име!
- No comment provided by engineer.
+ alert title
+
+
+ Invalid relay address!
+ alert title
+
+
+ Invalid relay name!
+ alert title
Invalid response
@@ -4484,6 +4811,10 @@ More improvements are coming soon!
Покани членове
No comment provided by engineer.
+
+ Invite someone privately
+ No comment provided by engineer.
+
Invite to chat
No comment provided by engineer.
@@ -4558,6 +4889,10 @@ More improvements are coming soon!
присъединяване като %@
No comment provided by engineer.
+
+ Join channel
+ No comment provided by engineer.
+
Join group
Влез в групата
@@ -4643,6 +4978,14 @@ This is your link for group %@!
Напусни
swipe action
+
+ Leave channel
+ No comment provided by engineer.
+
+
+ Leave channel?
+ No comment provided by engineer.
+
Leave chat
No comment provided by engineer.
@@ -4665,6 +5008,10 @@ This is your link for group %@!
Less traffic on mobile networks.
No comment provided by engineer.
+
+ Let someone connect to you
+ No comment provided by engineer.
+
Let's talk in SimpleX Chat
Нека да поговорим в SimpleX Chat
@@ -4685,6 +5032,10 @@ This is your link for group %@!
Свържете мобилни и настолни приложения! 🔗
No comment provided by engineer.
+
+ Link signature verified.
+ owner verification
+
Linked desktop options
Настройки на запомнени настолни устройства
@@ -4854,6 +5205,10 @@ This is your link for group %@!
Членовете на групата могат да добавят реакции към съобщенията.
No comment provided by engineer.
+
+ Members can chat with admins.
+ No comment provided by engineer.
+
Members can irreversibly delete sent messages. (24 hours)
Членовете на групата могат необратимо да изтриват изпратените съобщения. (24 часа)
@@ -4915,6 +5270,10 @@ This is your link for group %@!
Чернова на съобщение
No comment provided by engineer.
+
+ Message error
+ No comment provided by engineer.
+
Message forwarded
item status text
@@ -5000,6 +5359,14 @@ This is your link for group %@!
Съобщенията от %@ ще бъдат показани!
No comment provided by engineer.
+
+ Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages.
+ No comment provided by engineer.
+
+
+ Messages in this channel are not end-to-end encrypted. Chat relays can see these messages.
+ E2EE info chat item
+
Messages in this chat will never be deleted.
alert message
@@ -5026,16 +5393,15 @@ This is your link for group %@!
Съобщенията, файловете и разговорите са защитени чрез **квантово устойчиво e2e криптиране** с перфектна секретност при препращане, правдоподобно опровержение и възстановяване при взлом.
No comment provided by engineer.
+
+ Migrate
+ No comment provided by engineer.
+
Migrate device
Мигрирай устройството
No comment provided by engineer.
-
- Migrate from another device
- Мигриране от друго устройство
- No comment provided by engineer.
-
Migrate here
Мигрирай тук
@@ -5153,6 +5519,10 @@ This is your link for group %@!
Мрежа и сървъри
No comment provided by engineer.
+
+ Network commitments
+ No comment provided by engineer.
+
Network connection
Мрежова връзка
@@ -5162,6 +5532,10 @@ This is your link for group %@!
Network decentralization
No comment provided by engineer.
+
+ Network error
+ conn error description
+
Network issues - message expired after many attempts to send it.
snd error text
@@ -5175,6 +5549,11 @@ This is your link for group %@!
Network operator
No comment provided by engineer.
+
+ Network routers cannot know
+who talks to whom
+ No comment provided by engineer.
+
Network settings
Мрежови настройки
@@ -5189,6 +5568,10 @@ This is your link for group %@!
New
token status text
+
+ New 1-time link
+ No comment provided by engineer.
+
New Passcode
Нов kод за достъп
@@ -5211,6 +5594,10 @@ This is your link for group %@!
New chat experience 🎉
No comment provided by engineer.
+
+ New chat relay
+ No comment provided by engineer.
+
New contact request
Нова заявка за контакт
@@ -5276,11 +5663,28 @@ This is your link for group %@!
Не
No comment provided by engineer.
+
+ No account. No phone. No email. No ID.
+The most secure encryption.
+ No comment provided by engineer.
+
+
+ No active relays
+ No comment provided by engineer.
+
No app password
Приложението няма kод за достъп
Authentication unavailable
+
+ No chat relays
+ No comment provided by engineer.
+
+
+ No chat relays enabled.
+ servers warning
+
No chats
No comment provided by engineer.
@@ -5408,11 +5812,22 @@ This is your link for group %@!
No unread chats
No comment provided by engineer.
-
- No user identifiers.
- Първата платформа без никакви потребителски идентификатори – поверителна по дизайн.
+
+ Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life.
No comment provided by engineer.
+
+ Non-profit governance
+ No comment provided by engineer.
+
+
+ Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign.
+ No comment provided by engineer.
+
+
+ Not all relays connected
+ alert title
+
Not compatible!
Несъвместим!
@@ -5464,7 +5879,7 @@ This is your link for group %@!
OK
ОК
- No comment provided by engineer.
+ alert button
Off
@@ -5483,11 +5898,19 @@ new chat action
Стара база данни
No comment provided by engineer.
+
+ On your phone, not on servers.
+ No comment provided by engineer.
+
One-time invitation link
Линк за еднократна покана
No comment provided by engineer.
+
+ One-time link
+ chat link info line
+
Onion hosts will be **required** for connection.
Requires compatible VPN.
@@ -5507,6 +5930,10 @@ Requires compatible VPN.
Няма се използват Onion хостове.
No comment provided by engineer.
+
+ Only channel owners can change channel preferences.
+ No comment provided by engineer.
+
Only chat owners can change preferences.
No comment provided by engineer.
@@ -5604,7 +6031,8 @@ Requires compatible VPN.
Open
Отвори
- alert action
+ alert action
+alert button
Open Settings
@@ -5615,6 +6043,10 @@ Requires compatible VPN.
Open changes
No comment provided by engineer.
+
+ Open channel
+ new chat action
+
Open chat
Отвори чат
@@ -5633,6 +6065,10 @@ Requires compatible VPN.
Open conditions
No comment provided by engineer.
+
+ Open external link?
+ alert title
+
Open full link
alert action
@@ -5651,6 +6087,10 @@ Requires compatible VPN.
Отвори миграцията към друго устройство
authentication reason
+
+ Open new channel
+ new chat action
+
Open new chat
new chat action
@@ -5688,6 +6128,13 @@ Requires compatible VPN.
Operator server
alert title
+
+ Operators commit to:
+- Be independent
+- Minimize metadata usage
+- Run verified open-source code
+ No comment provided by engineer.
+
Or import archive file
No comment provided by engineer.
@@ -5707,6 +6154,10 @@ Requires compatible VPN.
Или сигурно споделете този линк към файла
No comment provided by engineer.
+
+ Or show QR in person or via video call.
+ No comment provided by engineer.
+
Or show this code
Или покажи този код
@@ -5716,6 +6167,10 @@ Requires compatible VPN.
Or to share privately
No comment provided by engineer.
+
+ Or use this QR - print or show online.
+ No comment provided by engineer.
+
Organize chats into lists
No comment provided by engineer.
@@ -5730,6 +6185,18 @@ Requires compatible VPN.
%@
alert message
+
+ Owner
+ No comment provided by engineer.
+
+
+ Owners
+ No comment provided by engineer.
+
+
+ Ownership: you can run your own relays.
+ No comment provided by engineer.
+
PING count
PING бройка
@@ -5784,6 +6251,10 @@ Requires compatible VPN.
Постави изображение
No comment provided by engineer.
+
+ Paste link / Scan
+ No comment provided by engineer.
+
Paste link to connect!
Поставете линк, за да се свържете!
@@ -5928,6 +6399,14 @@ Error: %@
Запазете последната чернова на съобщението с прикачени файлове.
No comment provided by engineer.
+
+ Preset relay address
+ No comment provided by engineer.
+
+
+ Preset relay name
+ No comment provided by engineer.
+
Preset server address
Предварително зададен адрес на сървъра
@@ -5959,13 +6438,12 @@ Error: %@
Privacy policy and conditions of use.
No comment provided by engineer.
-
- Privacy redefined
- Поверителността преосмислена
+
+ Privacy: for owners and subscribers.
No comment provided by engineer.
-
- Private chats, groups and your contacts are not accessible to server operators.
+
+ Private and secure messaging.
No comment provided by engineer.
@@ -6002,6 +6480,10 @@ Error: %@
Private routing timeout
alert title
+
+ Proceed
+ alert action
+
Profile and server connections
Профилни и сървърни връзки
@@ -6026,9 +6508,8 @@ Error: %@
Profile theme
No comment provided by engineer.
-
- Profile update will be sent to your contacts.
- Актуализацията на профила ще бъде изпратена до вашите контакти.
+
+ Profile update will be sent to your SimpleX contacts.
alert message
@@ -6036,6 +6517,10 @@ Error: %@
Забрани аудио/видео разговорите.
No comment provided by engineer.
+
+ Prohibit chats with admins.
+ No comment provided by engineer.
+
Prohibit irreversible message deletion.
Забрани необратимото изтриване на съобщения.
@@ -6065,6 +6550,10 @@ Error: %@
Забрани изпращането на лични съобщения до членовете.
No comment provided by engineer.
+
+ Prohibit sending direct messages to subscribers.
+ No comment provided by engineer.
+
Prohibit sending disappearing messages.
Забрани изпращането на изчезващи съобщения.
@@ -6125,6 +6614,10 @@ Enable in *Network & servers* settings.
Proxy requires password
No comment provided by engineer.
+
+ Public channels - speak freely 🚀
+ No comment provided by engineer.
+
Push notifications
Push известия
@@ -6164,24 +6657,14 @@ Enable in *Network & servers* settings.
Прочетете още
No comment provided by engineer.
-
- Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).
- Прочетете повече в [Ръководство за потребителя](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).
+
+ Read more in User Guide.
+ Прочетете повече в Ръководство за потребителя.
No comment provided by engineer.
-
- Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).
- Прочетете повече в [Ръководство за потребителя](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).
- No comment provided by engineer.
-
-
- Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).
- Прочетете повече в [Ръководство на потребителя](https://simplex.chat/docs/guide/readme.html#connect-to-friends).
- No comment provided by engineer.
-
-
- Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).
- Прочетете повече в нашето [GitHub хранилище](https://github.com/simplex-chat/simplex-chat#readme).
+
+ Read more in our GitHub repository.
+ Прочетете повече в нашето GitHub хранилище.
No comment provided by engineer.
@@ -6328,6 +6811,26 @@ swipe action
Reject member?
alert title
+
+ Relay
+ No comment provided by engineer.
+
+
+ Relay address
+ alert title
+
+
+ Relay connection failed
+ alert title
+
+
+ Relay link
+ No comment provided by engineer.
+
+
+ Relay results:
+ alert message
+
Relay server is only used if necessary. Another party can observe your IP address.
Реле сървър се използва само ако е необходимо. Друга страна може да наблюдава вашия IP адрес.
@@ -6338,6 +6841,14 @@ swipe action
Relay сървърът защитава вашия IP адрес, но може да наблюдава продължителността на разговора.
No comment provided by engineer.
+
+ Relay test failed!
+ No comment provided by engineer.
+
+
+ Reliability: many relays per channel.
+ No comment provided by engineer.
+
Remove
Премахване
@@ -6374,6 +6885,14 @@ swipe action
Премахване на паролата от keychain?
No comment provided by engineer.
+
+ Remove subscriber
+ No comment provided by engineer.
+
+
+ Remove subscriber?
+ alert title
+
Removes messages and blocks members.
No comment provided by engineer.
@@ -6586,6 +7105,10 @@ swipe action
SOCKS proxy
No comment provided by engineer.
+
+ Safe web links
+ No comment provided by engineer.
+
Safely receive files
No comment provided by engineer.
@@ -6610,6 +7133,10 @@ chat item action
Save (and notify members)
alert button
+
+ Save (and notify subscribers)
+ alert button
+
Save admission settings?
alert title
@@ -6624,6 +7151,10 @@ chat item action
Запази и уведоми членовете на групата
No comment provided by engineer.
+
+ Save and notify subscribers
+ No comment provided by engineer.
+
Save and reconnect
No comment provided by engineer.
@@ -6633,6 +7164,14 @@ chat item action
Запази и актуализирай профила на групата
No comment provided by engineer.
+
+ Save channel profile
+ No comment provided by engineer.
+
+
+ Save channel profile?
+ alert title
+
Save group profile
Запази профила на групата
@@ -6800,6 +7339,10 @@ chat item action
Код за сигурност
No comment provided by engineer.
+
+ Security: owners hold channel keys.
+ No comment provided by engineer.
+
Select
Избери
@@ -6919,6 +7462,10 @@ chat item action
Send request without message
No comment provided by engineer.
+
+ Send the link via any messenger - it's secure. Ask to paste into SimpleX.
+ No comment provided by engineer.
+
Send them from gallery or custom keyboards.
Изпрати от галерия или персонализирани клавиатури.
@@ -6929,6 +7476,10 @@ chat item action
Изпращане до последните 100 съобщения на нови членове.
No comment provided by engineer.
+
+ Send up to 100 last messages to new subscribers.
+ No comment provided by engineer.
+
Send your private feedback to groups.
No comment provided by engineer.
@@ -6943,6 +7494,10 @@ chat item action
Подателят може да е изтрил заявката за връзка.
No comment provided by engineer.
+
+ Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later.
+ alert message
+
Sending delivery receipts will be enabled for all contacts in all visible chat profiles.
Изпращането на потвърждениe за доставка ще бъде активирано за всички контакти във всички видими чат профили.
@@ -7055,6 +7610,10 @@ chat item action
Server protocol changed.
alert title
+
+ Server requires authorization to connect to relay, check password.
+ relay test error
+
Server requires authorization to create queues, check password.
Сървърът изисква оторизация за създаване на опашки, проверете паролата
@@ -7174,6 +7733,14 @@ chat item action
Settings were changed.
alert message
+
+ Setup notifications
+ No comment provided by engineer.
+
+
+ Setup routers
+ No comment provided by engineer.
+
Shape profile images
Променете формата на профилните изображения
@@ -7207,11 +7774,14 @@ chat item action
Share address publicly
No comment provided by engineer.
-
- Share address with contacts?
- Сподели адреса с контактите?
+
+ Share address with SimpleX contacts?
alert title
+
+ Share channel
+ No comment provided by engineer.
+
Share from other apps.
No comment provided by engineer.
@@ -7233,6 +7803,10 @@ chat item action
Share profile
No comment provided by engineer.
+
+ Share relay address
+ No comment provided by engineer.
+
Share this 1-time invite link
Сподели този еднократен линк за връзка
@@ -7242,9 +7816,12 @@ chat item action
Share to SimpleX
No comment provided by engineer.
-
- Share with contacts
- Сподели с контактите
+
+ Share via chat
+ No comment provided by engineer.
+
+
+ Share with SimpleX contacts
No comment provided by engineer.
@@ -7404,8 +7981,8 @@ chat item action
SimpleX protocols reviewed by Trail of Bits.
No comment provided by engineer.
-
- SimpleX relay link
+
+ SimpleX relay address
simplex link type
@@ -7473,6 +8050,11 @@ report reason
Квадрат, кръг или нещо между тях.
No comment provided by engineer.
+
+ Star on GitHub
+ Звезда в GitHub
+ No comment provided by engineer.
+
Start chat
Започни чат
@@ -7568,6 +8150,63 @@ report reason
Subscribed
No comment provided by engineer.
+
+ Subscriber
+ No comment provided by engineer.
+
+
+ Subscriber reports
+ chat feature
+
+
+ Subscriber will be removed from channel - this cannot be undone!
+ alert message
+
+
+ Subscribers
+ No comment provided by engineer.
+
+
+ Subscribers can add message reactions.
+ No comment provided by engineer.
+
+
+ Subscribers can chat with admins.
+ No comment provided by engineer.
+
+
+ Subscribers can irreversibly delete sent messages. (24 hours)
+ No comment provided by engineer.
+
+
+ Subscribers can report messsages to moderators.
+ No comment provided by engineer.
+
+
+ Subscribers can send SimpleX links.
+ No comment provided by engineer.
+
+
+ Subscribers can send direct messages.
+ No comment provided by engineer.
+
+
+ Subscribers can send disappearing messages.
+ No comment provided by engineer.
+
+
+ Subscribers can send files and media.
+ No comment provided by engineer.
+
+
+ Subscribers can send voice messages.
+ No comment provided by engineer.
+
+
+ Subscribers use relay link to connect to the channel.
+Relay address was used to set up this relay for the channel.
+ No comment provided by engineer.
+
Subscription errors
No comment provided by engineer.
@@ -7640,6 +8279,10 @@ report reason
Направи снимка
No comment provided by engineer.
+
+ Talk to someone
+ No comment provided by engineer.
+
Tap Connect to chat
No comment provided by engineer.
@@ -7652,8 +8295,8 @@ report reason
Tap Connect to use bot
No comment provided by engineer.
-