Merge branch 'master' into ep/smp-server-pages

This commit is contained in:
Evgeny Poberezkin
2026-05-11 12:07:50 +01:00
527 changed files with 52026 additions and 8517 deletions
+136 -13
View File
@@ -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:
+40 -26
View File
@@ -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
+2 -2
View File
@@ -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.
[<img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
&nbsp;
+4
View File
@@ -69,3 +69,7 @@ Libraries/
Shared/MyPlayground.playground/*
testpush.sh
# Local build config and generated assets
Local.xcconfig
Shared/SimpleXAssets.xcassets/*.imageset
+2
View File
@@ -0,0 +1,2 @@
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
#include? "Local.xcconfig"
+21
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
#include? "Local.xcconfig"
+38 -5
View File
@@ -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<Int64>, 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
+6 -3
View File
@@ -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)
}
}
}
+56 -6
View File
@@ -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<Bool>) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) {
func apiConnectPlan(connLink: String, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")
return (nil, nil)
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink), inProgress: inProgress)
let r: APIResult<ChatResponse1>? = 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>(_ r: APIResult<R>) -> 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<ChatResponse2>? = 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<ChatResponse2>? = 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
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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)
}
}
}
File diff suppressed because one or more lines are too long
@@ -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)
@@ -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())
@@ -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<Int> = []
@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<Int>?,
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")
@@ -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<ComposeState>? = 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)
}
}
+15 -8
View File
@@ -145,6 +145,7 @@ struct ChatItemContentView<Content: View>: 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<Content: View>: 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<Content: View>: 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<Content: View>: 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<Content: View>: 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<Content: View>: 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 {
+8 -6
View File
@@ -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<Int>?, _ 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)
@@ -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)
}
}
@@ -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
@@ -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("")
}
}
@@ -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<Int64>
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<Int64> = []
@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))
}
}
}
}
}
@@ -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)
}
}
}
@@ -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<ComposeState>? = 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<ComposeState>? = 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: ""
)
@@ -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<ComposeState>? = 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() {
@@ -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)
}
}
@@ -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<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View {
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = 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 {
@@ -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)
}
@@ -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 {
@@ -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:
@@ -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)
@@ -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 {
@@ -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)
@@ -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
@@ -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) {
+106 -44
View File
@@ -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<Label: View>: 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<Content: View>(
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<Content: View>(
profileFullName: profileFullName,
profileImage: hostedView,
subtitle: subtitle,
information: information,
cancelTitle: cancelTitle,
confirmTitle: confirmTitle,
onCancel: onCancel,
@@ -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)
@@ -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)
@@ -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()
@@ -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")
}
}
+143 -30
View File
@@ -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<Bool>) {
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!",
@@ -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<EnvironmentValues, RefreshAction?>, nil)
}
.sheet(isPresented: $showInviteSomeone) {
NavigationView {
NewChatView(selection: .invite, onboarding: true)
.modifier(ThemedBackground(grouped: true))
}
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
}
.sheet(isPresented: $showCreateAddress) {
NavigationView {
UserAddressView(autoCreate: true, onboarding: true)
.modifier(ThemedBackground(grouped: true))
}
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
}
}
@ViewBuilder
private func cardPair<C1: View, C2: View>(
_ 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)
}
}
}
@@ -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()
}
@@ -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<Int64>()
@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<Int64>
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: [])
}
@@ -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)
}
}
@@ -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())
}
@@ -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)
)
@@ -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 }
@@ -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))
}
}
@@ -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))
@@ -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")
@@ -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<Int64>()
@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
)
}
}
}
}
}
}
@@ -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)
@@ -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)
}
}
@@ -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))
@@ -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)
}
@@ -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]>,
@@ -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")
}
}
@@ -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")
@@ -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) {
@@ -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")
@@ -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)
}
}
}
@@ -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)
}
@@ -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"),
@@ -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 }
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -68,7 +68,7 @@ func apiSendMessages(
type: chatInfo.chatType,
id: chatInfo.apiId,
scope: chatInfo.groupChatScope(),
sendAsGroup: chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false,
sendAsGroup: chatInfo.sendAsGroup,
live: false,
ttl: nil,
composedMessages: composedMessages
+1 -2
View File
@@ -465,8 +465,7 @@ fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result<SharedCo
case .url:
if let url = try? await ip.loadItem(forTypeIdentifier: type.identifier) as? URL {
let content: SharedContent
if privacyLinkPreviewsGroupDefault.get(), let linkPreview = await getLinkPreview(for: url) {
privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5
if privacyLinkPreviewsGroupDefault.get() && !privacyLinkPreviewsShowAlertGroupDefault.get(), let linkPreview = await getLinkPreview(for: url) {
content = .url(preview: linkPreview)
} else {
content = .text(string: url.absoluteString)
@@ -80,7 +80,7 @@
"Please create a profile in the SimpleX app" = "Bitte erstellen Sie in der SimpleX-App ein Profil";
/* No comment provided by engineer. */
"Selected chat preferences prohibit this message." = "Die gewählten Chat-Einstellungen erlauben diese Nachricht nicht.";
"Selected chat preferences prohibit this message." = "Diese Nachricht ist wegen der gewählten Chat-Präferenzen nicht erlaubt.";
/* No comment provided by engineer. */
"Sending a message takes longer than expected." = "Das Senden einer Nachricht dauert länger als erwartet.";
@@ -29,7 +29,7 @@
"Database error" = "Ошибка базы данных";
/* No comment provided by engineer. */
"Database passphrase is different from saved in the keychain." = "Пароль базы данных отличается от сохраненного в keychain.";
"Database passphrase is different from saved in the keychain." = "Пароль базы данных отличается от сохранённого в keychain.";
/* No comment provided by engineer. */
"Database passphrase is required to open chat." = "Введите пароль базы данных, чтобы открыть чат.";
@@ -77,7 +77,7 @@
"Passphrase" = "Пароль";
/* No comment provided by engineer. */
"Please create a profile in the SimpleX app" = "Пожалуйста, создайте профиль в приложении SimpleX.";
"Please create a profile in the SimpleX app" = "Пожалуйста, создайте профиль в приложении SimpleX";
/* No comment provided by engineer. */
"Selected chat preferences prohibit this message." = "Выбранные настройки чата запрещают это сообщение.";
+90 -33
View File
@@ -169,6 +169,7 @@
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; };
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; };
6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7072F48D0000060512B /* AddGroupRelayView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; };
@@ -182,8 +183,8 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -225,7 +226,6 @@
B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; };
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; };
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; };
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; };
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; };
@@ -250,12 +250,19 @@
D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; };
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; };
E559A0A12E3F77EE00B26F74 /* CommandsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */; };
E5A0B0012F960000AAAA0001 /* YourNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A0B0022F960000AAAA0001 /* YourNetwork.swift */; };
E5AEC0AB2F91A6EB00270665 /* CIChatLinkHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */; };
E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; };
E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; };
E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; };
E5DBF1932F88169800E1D7FD /* ConnectBannerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */; };
E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; };
E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; };
E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9962C5906FF007928CC /* InfoPlist.strings */; };
E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */; };
E5DDBE702DC4217900A0EFF0 /* NSEAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */; };
E5E418012F83D2CA00252B9E /* OnboardingCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E418002F83D2CA00252B9E /* OnboardingCards.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -540,6 +547,7 @@
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = "<group>"; };
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = "<group>"; };
6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = "<group>"; };
@@ -553,8 +561,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -594,7 +602,6 @@
B70CE9E52D4BE5930080F36D /* GroupMentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMentions.swift; sourceTree = "<group>"; };
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = "<group>"; };
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
@@ -617,6 +624,13 @@
D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = "<group>"; };
E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsMenuView.swift; sourceTree = "<group>"; };
E5A0B0022F960000AAAA0001 /* YourNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YourNetwork.swift; sourceTree = "<group>"; };
E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = "<group>"; };
E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = "<group>"; };
E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = "<group>"; };
E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectBannerCard.swift; sourceTree = "<group>"; };
E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
@@ -671,6 +685,7 @@
E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAPITypes.swift; sourceTree = "<group>"; };
E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEAPITypes.swift; sourceTree = "<group>"; };
E5E418002F83D2CA00252B9E /* OnboardingCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCards.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -716,8 +731,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -803,8 +818,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.12-ERy6t9H0AqxJf9JR5ehJBk.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -872,6 +887,8 @@
5CA059BD279559F40002BEB4 = {
isa = PBXGroup;
children = (
E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */,
E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */,
5C55A92D283D0FDE00C4E99E /* sounds */,
5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */,
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
@@ -897,6 +914,7 @@
5C764E87279CBC8E000C6508 /* Model */,
5C2E260D27A30E2400F70299 /* Views */,
5CA059C5279559F40002BEB4 /* Assets.xcassets */,
E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */,
D7AA2C3429A936B400737B40 /* MediaEncryption.playground */,
5C13730C2815740A00F43030 /* DebugJSON.playground */,
);
@@ -943,7 +961,8 @@
5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */,
5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */,
640743602CD360E600158442 /* ChooseServerOperators.swift */,
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */,
E5A0B0022F960000AAAA0001 /* YourNetwork.swift */,
E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */,
);
path = Onboarding;
sourceTree = "<group>";
@@ -965,6 +984,7 @@
640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */,
640417CC2B29B8C200CCB412 /* NewChatView.swift */,
5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */,
E5E418002F83D2CA00252B9E /* OnboardingCards.swift */,
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */,
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */,
647B15E72F4C8D2500EB431E /* AddChannelView.swift */,
@@ -1079,6 +1099,7 @@
6440C9FF288857A10062C672 /* CIEventView.swift */,
5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */,
5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */,
E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */,
644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */,
644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */,
1841511920742C6E152E469F /* AnimatedImageView.swift */,
@@ -1095,6 +1116,7 @@
children = (
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */,
5CEACCE227DE9246000BD591 /* ComposeView.swift */,
E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */,
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */,
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */,
6454036E2822A9750090DDFF /* ComposeFileView.swift */,
@@ -1153,6 +1175,7 @@
64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */,
6495D7032F48CFC50060512B /* ChannelMembersView.swift */,
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */,
6495D7072F48D0000060512B /* AddGroupRelayView.swift */,
);
path = Group;
sourceTree = "<group>";
@@ -1230,6 +1253,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 5CA059F3279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (iOS)" */;
buildPhases = (
E5C0BBF02F82B50C00EA7527 /* Run Script */,
5CA059C6279559F40002BEB4 /* Sources */,
5CA059C7279559F40002BEB4 /* Frameworks */,
5CA059C8279559F40002BEB4 /* Resources */,
@@ -1420,6 +1444,7 @@
5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */,
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */,
5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */,
E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1451,12 +1476,35 @@
buildActionMask = 2147483647;
files = (
E5DCF9712C590272007928CC /* Localizable.strings in Resources */,
E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */,
E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
E5C0BBF02F82B50C00EA7527 /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Run Script";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "${SRCROOT}/../../scripts/ios/copy-assets.sh\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
5CA059C6279559F40002BEB4 /* Sources */ = {
isa = PBXSourcesBuildPhase;
@@ -1467,13 +1515,14 @@
640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */,
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */,
640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */,
E5A0B0012F960000AAAA0001 /* YourNetwork.swift in Sources */,
64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */,
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,
E5E418012F83D2CA00252B9E /* OnboardingCards.swift in Sources */,
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */,
64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */,
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */,
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */,
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */,
5C65DAF929D0CC20003CEE45 /* DeveloperView.swift in Sources */,
@@ -1587,10 +1636,12 @@
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */,
647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */,
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */,
6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */,
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */,
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */,
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
8CAEF1502D11A6A000240F00 /* ChatItemsLoader.swift in Sources */,
@@ -1604,6 +1655,7 @@
8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */,
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */,
E5AEC0AB2F91A6EB00270665 /* CIChatLinkHeader.swift in Sources */,
B70A39732D24090D00E80A5F /* TagListView.swift in Sources */,
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */,
64E5E3632DF71A4E00A4D530 /* ContextContactRequestActionsView.swift in Sources */,
@@ -1612,6 +1664,7 @@
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */,
8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */,
E5DBF1932F88169800E1D7FD /* ConnectBannerCard.swift in Sources */,
64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */,
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */,
@@ -1891,6 +1944,7 @@
/* Begin XCBuildConfiguration section */
5CA059F1279559F40002BEB4 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
@@ -1945,7 +1999,6 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -1953,6 +2006,7 @@
};
5CA059F2279559F40002BEB4 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
@@ -2019,7 +2073,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 331;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2044,7 +2098,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES_THIN;
MARKETING_VERSION = 6.5;
MARKETING_VERSION = 6.5.1;
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
@@ -2069,7 +2123,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 331;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2094,7 +2148,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.5;
MARKETING_VERSION = 6.5.1;
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
@@ -2111,11 +2165,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 331;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.5;
MARKETING_VERSION = 6.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2131,11 +2185,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 331;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.5;
MARKETING_VERSION = 6.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2156,7 +2210,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 331;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2171,7 +2225,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.5;
MARKETING_VERSION = 6.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2193,7 +2247,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 331;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2208,7 +2262,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.5;
MARKETING_VERSION = 6.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2230,7 +2284,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 331;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2256,7 +2310,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.5;
MARKETING_VERSION = 6.5.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2281,7 +2335,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 331;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2289,6 +2343,7 @@
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
EXPORTED_SYMBOLS_FILE = "$(PROJECT_DIR)/SimpleXChat/exported_symbols.txt";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 SimpleX Chat. All rights reserved.";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
@@ -2307,11 +2362,13 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.5;
MARKETING_VERSION = 6.5.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
STRIP_INSTALLED_PRODUCT = YES;
STRIP_STYLE = "non-global";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_INCLUDE_PATHS = "";
@@ -2332,7 +2389,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 331;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2347,7 +2404,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.5;
MARKETING_VERSION = 6.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2366,7 +2423,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 324;
CURRENT_PROJECT_VERSION = 331;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2381,7 +2438,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.5;
MARKETING_VERSION = 6.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -1,5 +1,5 @@
{
"originHash" : "07434ae88cbf078ce3d27c91c1f605836aaebff0e0cef5f25317795151c77db1",
"originHash" : "60aeecb7917535a5e44ade0dbb5411ab112a959283e565a04c212c8af4e7dee9",
"pins" : [
{
"identity" : "codescanner",
+42 -40
View File
@@ -26,7 +26,7 @@ public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer
let GROUP_DEFAULT_APP_LOCAL_AUTH_ENABLED = "appLocalAuthEnabled"
public let GROUP_DEFAULT_ALLOW_SHARE_EXTENSION = "allowShareExtension"
// replaces DEFAULT_PRIVACY_LINK_PREVIEWS
let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
public let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
public let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "privacyLinkPreviewsShowAlert"
public let GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS = "privacySanitizeLinks"
// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES
@@ -70,46 +70,48 @@ public let APP_GROUP_NAME = "group.chat.simplex.app"
public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)!
public let groupAppDefaults: [String: Any] = [
GROUP_DEFAULT_NTF_ENABLE_LOCAL: false,
GROUP_DEFAULT_NTF_ENABLE_PERIODIC: false,
GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue,
GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue,
GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue,
GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue,
GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS: SMPWebPortServers.preset.rawValue,
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpConnectTimeout.backgroundTimeout,
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpConnectTimeout.interactiveTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpTimeout.backgroundTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpTimeout.interactiveTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb,
GROUP_DEFAULT_NETWORK_RCV_CONCURRENCY: NetCfg.defaults.rcvConcurrency,
GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval,
GROUP_DEFAULT_NETWORK_SMP_PING_COUNT: NetCfg.defaults.smpPingCount,
GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE: NetCfg.defaults.enableKeepAlive,
GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE: KeepAliveOpts.defaults.keepIdle,
GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL: KeepAliveOpts.defaults.keepIntvl,
GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt,
GROUP_DEFAULT_INCOGNITO: false,
GROUP_DEFAULT_STORE_DB_PASSPHRASE: true,
GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false,
GROUP_DEFAULT_APP_LOCAL_AUTH_ENABLED: true,
GROUP_DEFAULT_ALLOW_SHARE_EXTENSION: false,
GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS: true,
GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT: true,
GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: false,
GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false,
GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true,
GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS: true,
GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false,
GROUP_DEFAULT_CALL_KIT_ENABLED: true,
GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false,
GROUP_DEFAULT_ONE_HAND_UI: true,
GROUP_DEFAULT_CHAT_BOTTOM_BAR: true
]
public func registerGroupDefaults() {
groupDefaults.register(defaults: [
GROUP_DEFAULT_NTF_ENABLE_LOCAL: false,
GROUP_DEFAULT_NTF_ENABLE_PERIODIC: false,
GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue,
GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue,
GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue,
GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue,
GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS: SMPWebPortServers.preset.rawValue,
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpConnectTimeout.backgroundTimeout,
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpConnectTimeout.interactiveTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpTimeout.backgroundTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpTimeout.interactiveTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb,
GROUP_DEFAULT_NETWORK_RCV_CONCURRENCY: NetCfg.defaults.rcvConcurrency,
GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval,
GROUP_DEFAULT_NETWORK_SMP_PING_COUNT: NetCfg.defaults.smpPingCount,
GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE: NetCfg.defaults.enableKeepAlive,
GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE: KeepAliveOpts.defaults.keepIdle,
GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL: KeepAliveOpts.defaults.keepIntvl,
GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt,
GROUP_DEFAULT_INCOGNITO: false,
GROUP_DEFAULT_STORE_DB_PASSPHRASE: true,
GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false,
GROUP_DEFAULT_APP_LOCAL_AUTH_ENABLED: true,
GROUP_DEFAULT_ALLOW_SHARE_EXTENSION: false,
GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS: true,
GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT: true,
GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: false,
GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false,
GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true,
GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS: true,
GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false,
GROUP_DEFAULT_CALL_KIT_ENABLED: true,
GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false,
GROUP_DEFAULT_ONE_HAND_UI: true,
GROUP_DEFAULT_CHAT_BOTTOM_BAR: true
])
groupDefaults.register(defaults: groupAppDefaults)
}
public enum AppState: String, Codable {
+381 -73
View File
@@ -867,6 +867,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable {
case simplexLinks
case reports
case history
case support
public var id: Self { self }
@@ -888,10 +889,13 @@ public enum GroupFeature: String, Decodable, Feature, Hashable {
case .simplexLinks: true
case .reports: false
case .history: false
case .support: false
}
}
public var text: String {
public var text: String { text(isChannel: false) }
public func text(isChannel: Bool) -> String {
switch self {
case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature")
case .directMessages: return NSLocalizedString("Direct messages", comment: "chat feature")
@@ -900,8 +904,11 @@ public enum GroupFeature: String, Decodable, Feature, Hashable {
case .voice: return NSLocalizedString("Voice messages", comment: "chat feature")
case .files: return NSLocalizedString("Files and media", comment: "chat feature")
case .simplexLinks: return NSLocalizedString("SimpleX links", comment: "chat feature")
case .reports: return NSLocalizedString("Member reports", comment: "chat feature")
case .reports: return isChannel
? NSLocalizedString("Subscriber reports", comment: "chat feature")
: NSLocalizedString("Member reports", comment: "chat feature")
case .history: return NSLocalizedString("Visible history", comment: "chat feature")
case .support: return NSLocalizedString("Chat with admins", comment: "chat feature")
}
}
@@ -916,6 +923,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable {
case .simplexLinks: return "link.circle"
case .reports: return "flag"
case .history: return "clock"
case .support: return "questionmark.circle"
}
}
@@ -930,6 +938,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable {
case .simplexLinks: return "link.circle.fill"
case .reports: return "flag.fill"
case .history: return "clock.fill"
case .support: return "questionmark.circle.fill"
}
}
@@ -940,7 +949,7 @@ public enum GroupFeature: String, Decodable, Feature, Hashable {
}
}
public func enableDescription(_ enabled: GroupFeatureEnabled, _ canEdit: Bool) -> LocalizedStringKey {
public func enableDescription(_ enabled: GroupFeatureEnabled, _ canEdit: Bool, isChannel: Bool = false) -> LocalizedStringKey {
if canEdit {
switch self {
case .timedMessages:
@@ -950,8 +959,12 @@ public enum GroupFeature: String, Decodable, Feature, Hashable {
}
case .directMessages:
switch enabled {
case .on: return "Allow sending direct messages to members."
case .off: return "Prohibit sending direct messages to members."
case .on: return isChannel
? "Allow sending direct messages to subscribers."
: "Allow sending direct messages to members."
case .off: return isChannel
? "Prohibit sending direct messages to subscribers."
: "Prohibit sending direct messages to members."
}
case .fullDelete:
switch enabled {
@@ -985,56 +998,96 @@ public enum GroupFeature: String, Decodable, Feature, Hashable {
}
case .history:
switch enabled {
case .on: return "Send up to 100 last messages to new members."
case .off: return "Do not send history to new members."
case .on: return isChannel
? "Send up to 100 last messages to new subscribers."
: "Send up to 100 last messages to new members."
case .off: return isChannel
? "Do not send history to new subscribers."
: "Do not send history to new members."
}
case .support:
switch enabled {
case .on: return isChannel
? "Allow subscribers to chat with admins."
: "Allow members to chat with admins."
case .off: return "Prohibit chats with admins."
}
}
} else {
switch self {
case .timedMessages:
switch enabled {
case .on: return "Members can send disappearing messages."
case .on: return isChannel
? "Subscribers can send disappearing messages."
: "Members can send disappearing messages."
case .off: return "Disappearing messages are prohibited."
}
case .directMessages:
switch enabled {
case .on: return "Members can send direct messages."
case .off: return "Direct messages between members are prohibited."
case .on: return isChannel
? "Subscribers can send direct messages."
: "Members can send direct messages."
case .off: return isChannel
? "Direct messages between subscribers are prohibited."
: "Direct messages between members are prohibited."
}
case .fullDelete:
switch enabled {
case .on: return "Members can irreversibly delete sent messages. (24 hours)"
case .on: return isChannel
? "Subscribers can irreversibly delete sent messages. (24 hours)"
: "Members can irreversibly delete sent messages. (24 hours)"
case .off: return "Irreversible message deletion is prohibited."
}
case .reactions:
switch enabled {
case .on: return "Members can add message reactions."
case .on: return isChannel
? "Subscribers can add message reactions."
: "Members can add message reactions."
case .off: return "Message reactions are prohibited."
}
case .voice:
switch enabled {
case .on: return "Members can send voice messages."
case .on: return isChannel
? "Subscribers can send voice messages."
: "Members can send voice messages."
case .off: return "Voice messages are prohibited."
}
case .files:
switch enabled {
case .on: return "Members can send files and media."
case .on: return isChannel
? "Subscribers can send files and media."
: "Members can send files and media."
case .off: return "Files and media are prohibited."
}
case .simplexLinks:
switch enabled {
case .on: return "Members can send SimpleX links."
case .on: return isChannel
? "Subscribers can send SimpleX links."
: "Members can send SimpleX links."
case .off: return "SimpleX links are prohibited."
}
case .reports:
switch enabled {
case .on: return "Members can report messsages to moderators."
case .on: return isChannel
? "Subscribers can report messsages to moderators."
: "Members can report messsages to moderators."
case .off: return "Reporting messages to moderators is prohibited."
}
case .history:
switch enabled {
case .on: return "Up to 100 last messages are sent to new members."
case .off: return "History is not sent to new members."
case .on: return isChannel
? "Up to 100 last messages are sent to new subscribers."
: "Up to 100 last messages are sent to new members."
case .off: return isChannel
? "History is not sent to new subscribers."
: "History is not sent to new members."
}
case .support:
switch enabled {
case .on: return isChannel
? "Subscribers can chat with admins."
: "Members can chat with admins."
case .off: return "Chats with admins are prohibited."
}
}
}
@@ -1190,6 +1243,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable {
public var simplexLinks: RoleGroupPreference
public var reports: GroupPreference
public var history: GroupPreference
public var support: GroupPreference
public var commands: [ChatBotCommand]
public init(
@@ -1202,6 +1256,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable {
simplexLinks: RoleGroupPreference,
reports: GroupPreference,
history: GroupPreference,
support: GroupPreference,
commands: [ChatBotCommand]
) {
self.timedMessages = timedMessages
@@ -1213,6 +1268,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable {
self.simplexLinks = simplexLinks
self.reports = reports
self.history = history
self.support = support
self.commands = commands
}
@@ -1226,6 +1282,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable {
simplexLinks: RoleGroupPreference(enable: .on, role: nil),
reports: GroupPreference(enable: .on),
history: GroupPreference(enable: .on),
support: GroupPreference(enable: .on),
commands: []
)
}
@@ -1240,6 +1297,7 @@ public struct GroupPreferences: Codable, Hashable {
public var simplexLinks: RoleGroupPreference?
public var reports: GroupPreference?
public var history: GroupPreference?
public var support: GroupPreference?
public var commands: [ChatBotCommand]?
public init(
@@ -1252,6 +1310,7 @@ public struct GroupPreferences: Codable, Hashable {
simplexLinks: RoleGroupPreference? = nil,
reports: GroupPreference? = nil,
history: GroupPreference? = nil,
support: GroupPreference? = nil,
commands: [ChatBotCommand]? = nil
) {
self.timedMessages = timedMessages
@@ -1263,6 +1322,7 @@ public struct GroupPreferences: Codable, Hashable {
self.simplexLinks = simplexLinks
self.reports = reports
self.history = history
self.support = support
self.commands = commands
}
@@ -1276,6 +1336,7 @@ public struct GroupPreferences: Codable, Hashable {
simplexLinks: RoleGroupPreference(enable: .on, role: nil),
reports: GroupPreference(enable: .on),
history: GroupPreference(enable: .on),
support: GroupPreference(enable: .on),
commands: nil
)
}
@@ -1563,8 +1624,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
public var userCantSendReason: (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? {
get {
public func userCantSendReason(allRelaysBroken: Bool = false) -> (composeLabel: LocalizedStringKey, alertMessage: LocalizedStringKey?)? {
switch self {
case let .direct(contact):
if contact.sendMsgToConnect { return nil }
@@ -1578,6 +1638,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
if groupInfo.membership.memberActive {
switch(groupChatScope) {
case .none:
if allRelaysBroken && groupInfo.useRelays { return ("can't broadcast", nil) }
if groupInfo.membership.memberPending { return ("reviewed by admins", "Please contact group admin.") }
if groupInfo.membership.memberRole == .observer {
return groupInfo.useRelays ? ("you are subscriber", nil) : ("you are observer", "Please contact group admin.")
@@ -1613,10 +1674,9 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
case .invalidJSON:
return ("can't send messages", nil)
}
}
}
public var sendMsgEnabled: Bool { userCantSendReason == nil }
public var sendMsgEnabled: Bool { userCantSendReason() == nil }
public var incognito: Bool {
get {
@@ -1652,6 +1712,10 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
public var isChannel: Bool {
groupInfo?.useRelays == true
}
// this works for features that are common for contacts and groups
public func featureEnabled(_ feature: ChatFeature) -> Bool {
switch self {
@@ -1757,6 +1821,18 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
public var sendAsGroup: Bool {
if let g = groupInfo, g.useRelays && g.membership.memberRole >= .owner {
switch groupChatScope() {
case .none: true
case .memberSupport: false
case .reports: false
}
} else {
false
}
}
public func ntfsEnabled(chatItem: ChatItem) -> Bool {
ntfsEnabled(chatItem.meta.userMention)
}
@@ -2368,6 +2444,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
public var ready: Bool { get { true } }
public var nextConnectPrepared: Bool { if let preparedGroup { !preparedGroup.connLinkStartedConnection } else { false } }
public var profileChangeProhibited: Bool { preparedGroup?.connLinkPreparedConnection ?? false }
public var isChannel: Bool { groupProfile.isChannel }
public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias }
public var fullName: String { get { groupProfile.fullName } }
public var shortDescr: String? { groupProfile.shortDescr }
@@ -2496,6 +2573,8 @@ public struct GroupProfile: Codable, NamedChat, Hashable {
set { memberAdmission = newValue }
}
public var isChannel: Bool { publicGroup?.groupType == .channel }
public static let sampleData = GroupProfile(
displayName: "team",
fullName: "My Team"
@@ -2560,6 +2639,7 @@ public enum RelayStatus: String, Decodable, Equatable, Hashable {
case rsInvited = "invited"
case rsAccepted = "accepted"
case rsActive = "active"
case rsInactive = "inactive"
}
public struct RelayProfile: Codable, Equatable, Hashable {
@@ -2632,6 +2712,7 @@ extension RelayStatus {
case .rsInvited: "invited"
case .rsAccepted: "accepted"
case .rsActive: "active"
case .rsInactive: "inactive"
}
}
}
@@ -3191,11 +3272,13 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
public var timestampText: Text { meta.timestampText }
public var text: String {
switch (content.text, content.msgContent, file) {
public var text: String { text(isChannel: false) }
public func text(isChannel: Bool) -> String {
switch (content.text(isChannel: isChannel), content.msgContent, file) {
case let ("", .some(.voice(_, duration)), _): return "Voice message (\(durationText(duration)))"
case let ("", _, .some(file)): return file.fileName
default: return content.text
default: return content.text(isChannel: isChannel)
}
}
@@ -3265,6 +3348,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
case .rcvCall: return false // notification is shown on .callInvitation instead
case .rcvIntegrityError: return false
case .rcvDecryptionError: return false
case .rcvMsgError: return false
case .rcvGroupInvitation: return true
case .sndGroupInvitation: return false
case .rcvDirectEvent(rcvDirectEvent: let rcvDirectEvent):
@@ -4022,6 +4106,7 @@ public enum CIContent: Decodable, ItemContent, Hashable {
case rcvCall(status: CICallStatus, duration: Int)
case rcvIntegrityError(msgError: MsgErrorType)
case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32)
case rcvMsgError(rcvMsgError: RcvMsgError)
case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent)
@@ -4047,42 +4132,43 @@ public enum CIContent: Decodable, ItemContent, Hashable {
case chatBanner
case invalidJSON(json: Data?)
public var text: String {
get {
switch self {
case let .sndMsgContent(mc): return mc.text
case let .rcvMsgContent(mc): return mc.text
case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case let .sndCall(status, duration): return status.text(duration)
case let .rcvCall(status, duration): return status.text(duration)
case let .rcvIntegrityError(msgError): return msgError.text
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text
case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
case let .sndConnEvent(sndConnEvent): return sndConnEvent.text
case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text)
case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text)
case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item")
case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case .sndGroupE2EEInfo: return e2eeInfoNoPQStr
case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr
case .chatBanner: return ""
case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item")
}
public var text: String { text(isChannel: false) }
public func text(isChannel: Bool) -> String {
switch self {
case let .sndMsgContent(mc): return mc.text
case let .rcvMsgContent(mc): return mc.text
case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case let .sndCall(status, duration): return status.text(duration)
case let .rcvCall(status, duration): return status.text(duration)
case let .rcvIntegrityError(msgError): return msgError.text
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
case let .rcvMsgError(rcvMsgError): return rcvMsgError.text
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text
case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text(isChannel: isChannel)
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text(isChannel: isChannel)
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
case let .sndConnEvent(sndConnEvent): return sndConnEvent.text
case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param)
case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param)
case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role)
case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text)
case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text)
case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item")
case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item")
case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo)
case let .sndGroupE2EEInfo(e2eeInfo): return groupE2EEInfoStr(e2eeInfo)
case let .rcvGroupE2EEInfo(e2eeInfo): return groupE2EEInfoStr(e2eeInfo)
case .chatBanner: return ""
case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item")
}
}
@@ -4092,6 +4178,12 @@ public enum CIContent: Decodable, ItemContent, Hashable {
: e2eeInfoNoPQStr
}
private func groupE2EEInfoStr(_ e2eeInfo: E2EEInfo) -> String {
e2eeInfo.public == true
? NSLocalizedString("Messages in this channel are not end-to-end encrypted. Chat relays can see these messages.", comment: "E2EE info chat item")
: e2eeInfoNoPQStr
}
private var e2eeInfoNoPQStr: String {
NSLocalizedString("This chat is protected by end-to-end encryption.", comment: "E2EE info chat item")
}
@@ -4150,6 +4242,7 @@ public enum CIContent: Decodable, ItemContent, Hashable {
case .rcvCall: return true
case .rcvIntegrityError: return true
case .rcvDecryptionError: return true
case .rcvMsgError: return true
case .rcvGroupInvitation: return true
case .rcvModerated: return true
case .rcvBlocked: return true
@@ -4579,10 +4672,14 @@ public enum MsgContent: Equatable, Hashable {
case voice(text: String, duration: Int)
case file(String)
case report(text: String, reason: ReportReason)
case chat(text: String, chatLink: MsgChatLink)
case chat(text: String, chatLink: MsgChatLink, ownerSig: LinkOwnerSig?)
// TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
case unknown(type: String, text: String)
public var chatLinkStr: String? {
if case let .chat(_, chatLink, _) = self { chatLink.connLinkStr } else { nil }
}
public var text: String {
switch self {
case let .text(text): return text
@@ -4592,7 +4689,7 @@ public enum MsgContent: Equatable, Hashable {
case let .voice(text, _): return text
case let .file(text): return text
case let .report(text, _): return text
case let .chat(text, _): return text
case let .chat(text, _, _): return text
case let .unknown(_, text): return text
}
}
@@ -4655,6 +4752,7 @@ public enum MsgContent: Equatable, Hashable {
case duration
case reason
case chatLink
case ownerSig
}
public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool {
@@ -4666,7 +4764,7 @@ public enum MsgContent: Equatable, Hashable {
case let (.voice(lt, ld), .voice(rt, rd)): return lt == rt && ld == rd
case let (.file(lf), .file(rf)): return lf == rf
case let (.report(lt, lr), .report(rt, rr)): return lt == rt && lr == rr
case let (.chat(lt, ll), .chat(rt, rl)): return lt == rt && ll == rl
case let (.chat(lt, ll, ls), .chat(rt, rl, rs)): return lt == rt && ll == rl && ls == rs
case let (.unknown(lType, lt), .unknown(rType, rt)): return lType == rType && lt == rt
default: return false
}
@@ -4709,7 +4807,8 @@ extension MsgContent: Decodable {
case "chat":
let text = try container.decode(String.self, forKey: CodingKeys.text)
let chatLink = try container.decode(MsgChatLink.self, forKey: CodingKeys.chatLink)
self = .chat(text: text, chatLink: chatLink)
let ownerSig = try container.decodeIfPresent(LinkOwnerSig.self, forKey: CodingKeys.ownerSig)
self = .chat(text: text, chatLink: chatLink, ownerSig: ownerSig)
default:
let text = try? container.decode(String.self, forKey: CodingKeys.text)
self = .unknown(type: type, text: text ?? "unknown message format")
@@ -4751,10 +4850,11 @@ extension MsgContent: Encodable {
try container.encode("report", forKey: .type)
try container.encode(text, forKey: .text)
try container.encode(reason, forKey: .reason)
case let .chat(text, chatLink):
case let .chat(text, chatLink, ownerSig):
try container.encode("chat", forKey: .type)
try container.encode(text, forKey: .text)
try container.encode(chatLink, forKey: .chatLink)
try container.encodeIfPresent(ownerSig, forKey: .ownerSig)
// TODO use original JSON and type
case let .unknown(_, text):
try container.encode("text", forKey: .type)
@@ -4763,7 +4863,7 @@ extension MsgContent: Encodable {
}
}
public enum MsgContentTag: String, Decodable {
public enum MsgContentTag: Codable, Hashable {
case text
case link
case image
@@ -4771,12 +4871,195 @@ public enum MsgContentTag: String, Decodable {
case voice
case file
case report
case chat
case unknown(type: String)
public var rawValue: String {
switch self {
case .text: return "text"
case .link: return "link"
case .image: return "image"
case .video: return "video"
case .voice: return "voice"
case .file: return "file"
case .report: return "report"
case .chat: return "chat"
case let .unknown(type): return type
}
}
public init(from decoder: Decoder) throws {
let s = try decoder.singleValueContainer().decode(String.self)
switch s {
case "text": self = .text
case "link": self = .link
case "image": self = .image
case "video": self = .video
case "voice": self = .voice
case "file": self = .file
case "report": self = .report
case "chat": self = .chat
case "liveText": self = .text
default: self = .unknown(type: s)
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(rawValue)
}
}
public enum MsgChatLink: Codable, Equatable, Hashable {
public enum MsgChatLink: Equatable, Hashable {
case contact(connLink: String, profile: Profile, business: Bool)
case invitation(invLink: String, profile: Profile)
case group(connLink: String, groupProfile: GroupProfile)
public var isPublicGroup: Bool {
if case let .group(_, gp) = self { gp.publicGroup != nil } else { false }
}
public var connLinkStr: String {
switch self {
case let .group(connLink, _): connLink
case let .contact(connLink, _, _): connLink
case let .invitation(invLink, _): invLink
}
}
public var image: String? {
switch self {
case let .group(_, groupProfile): groupProfile.image
case let .contact(_, profile, _): profile.image
case let .invitation(_, profile): profile.image
}
}
public var displayName: String {
switch self {
case let .group(_, groupProfile): groupProfile.displayName
case let .contact(_, profile, _): profile.displayName
case let .invitation(_, profile): profile.displayName
}
}
public var iconName: String {
switch self {
case let .group(_, groupProfile):
groupProfile.isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill"
case let .contact(_, _, business):
business ? "briefcase.circle.fill" : "person.crop.circle.fill"
case .invitation:
"person.crop.circle.fill"
}
}
public var smallIconName: String {
switch self {
case let .group(_, groupProfile):
groupProfile.isChannel ? "antenna.radiowaves.left.and.right" : "person.2"
case let .contact(_, _, business):
business ? "briefcase" : "person"
case .invitation:
"person"
}
}
public var fullName: String {
switch self {
case let .group(_, groupProfile): groupProfile.fullName
case let .contact(_, profile, _): profile.fullName
case let .invitation(_, profile): profile.fullName
}
}
public var shortDescription: String? {
let s: String? = switch self {
case let .group(_, groupProfile): groupProfile.shortDescr
case let .contact(_, profile, _): profile.shortDescr
case let .invitation(_, profile): profile.shortDescr
}
if let d = s?.trimmingCharacters(in: .whitespacesAndNewlines), !d.isEmpty { return d }
return nil
}
public func infoLine(signed: Bool) -> String {
var s: String = switch self {
case let .group(_, groupProfile):
groupProfile.isChannel
? NSLocalizedString("Channel link", comment: "chat link info line")
: NSLocalizedString("Group link", comment: "chat link info line")
case let .contact(_, _, business):
business
? NSLocalizedString("Business address", comment: "chat link info line")
: NSLocalizedString("Contact address", comment: "chat link info line")
case .invitation:
NSLocalizedString("One-time link", comment: "chat link info line")
}
if signed {
s += " " + (
self.isPublicGroup
? NSLocalizedString("(from owner)", comment: "chat link info line")
: NSLocalizedString("(signed)", comment: "chat link info line")
)
}
return s
}
}
extension MsgChatLink: Decodable {
private enum CodingKeys: String, CodingKey {
case type, connLink, invLink, profile, business, groupProfile
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "contact":
let connLink = try container.decode(String.self, forKey: .connLink)
let profile = try container.decode(Profile.self, forKey: .profile)
let business = try container.decode(Bool.self, forKey: .business)
self = .contact(connLink: connLink, profile: profile, business: business)
case "invitation":
let invLink = try container.decode(String.self, forKey: .invLink)
let profile = try container.decode(Profile.self, forKey: .profile)
self = .invitation(invLink: invLink, profile: profile)
case "group":
let connLink = try container.decode(String.self, forKey: .connLink)
let groupProfile = try container.decode(GroupProfile.self, forKey: .groupProfile)
self = .group(connLink: connLink, groupProfile: groupProfile)
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown MsgChatLink type: \(type)")
}
}
}
extension MsgChatLink: Encodable {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .contact(connLink, profile, business):
try container.encode("contact", forKey: .type)
try container.encode(connLink, forKey: .connLink)
try container.encode(profile, forKey: .profile)
try container.encode(business, forKey: .business)
case let .invitation(invLink, profile):
try container.encode("invitation", forKey: .type)
try container.encode(invLink, forKey: .invLink)
try container.encode(profile, forKey: .profile)
case let .group(connLink, groupProfile):
try container.encode("group", forKey: .type)
try container.encode(connLink, forKey: .connLink)
try container.encode(groupProfile, forKey: .groupProfile)
}
}
}
public struct LinkOwnerSig: Codable, Equatable, Hashable {
public var ownerId: String?
public var chatBinding: String
public var ownerSig: String
}
public struct FormattedText: Decodable, Hashable {
@@ -5076,6 +5359,20 @@ public enum MsgErrorType: Decodable, Hashable {
}
}
public enum RcvMsgError: Decodable, Hashable {
case dropped(attempts: Int)
case parseError(parseError: String)
var text: String {
switch self {
case let .dropped(attempts):
String.localizedStringWithFormat(NSLocalizedString("removed (%d attempts)", comment: "receive error chat item"), attempts)
case let .parseError(parseError):
String.localizedStringWithFormat(NSLocalizedString("error: %@", comment: "receive error chat item"), parseError)
}
}
}
public struct CIGroupInvitation: Decodable, Hashable {
public var groupId: Int64
public var groupMemberId: Int64
@@ -5101,6 +5398,7 @@ public enum CIGroupInvitationStatus: String, Decodable, Hashable {
public struct E2EEInfo: Decodable, Hashable {
public var pqEnabled: Bool?
public var `public`: Bool?
}
public enum RcvDirectEvent: Decodable, Hashable {
@@ -5153,7 +5451,9 @@ public enum RcvGroupEvent: Decodable, Hashable {
case memberProfileUpdated(fromProfile: Profile, toProfile: Profile)
case newMemberPendingReview
var text: String {
var text: String { text(isChannel: false) }
func text(isChannel: Bool) -> String {
switch self {
case let .memberAdded(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.profileViewName)
@@ -5175,8 +5475,12 @@ public enum RcvGroupEvent: Decodable, Hashable {
case let .memberDeleted(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("removed %@", comment: "rcv group event chat item"), profile.profileViewName)
case .userDeleted: return NSLocalizedString("removed you", comment: "rcv group event chat item")
case .groupDeleted: return NSLocalizedString("deleted group", comment: "rcv group event chat item")
case .groupUpdated: return NSLocalizedString("updated group profile", comment: "rcv group event chat item")
case .groupDeleted: return isChannel
? NSLocalizedString("deleted channel", comment: "rcv group event chat item")
: NSLocalizedString("deleted group", comment: "rcv group event chat item")
case .groupUpdated: return isChannel
? NSLocalizedString("updated channel profile", comment: "rcv group event chat item")
: NSLocalizedString("updated group profile", comment: "rcv group event chat item")
case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item")
case .memberCreatedContact: return NSLocalizedString("requested connection", comment: "rcv group event chat item")
case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile)
@@ -5208,7 +5512,9 @@ public enum SndGroupEvent: Decodable, Hashable {
case memberAccepted(groupMemberId: Int64, profile: Profile)
case userPendingReview
var text: String {
var text: String { text(isChannel: false) }
func text(isChannel: Bool) -> String {
switch self {
case let .memberRole(_, profile, role):
return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text)
@@ -5223,7 +5529,9 @@ public enum SndGroupEvent: Decodable, Hashable {
case let .memberDeleted(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName)
case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item")
case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item")
case .groupUpdated: return isChannel
? NSLocalizedString("channel profile updated", comment: "snd group event chat item")
: NSLocalizedString("group profile updated", comment: "snd group event chat item")
case .memberAccepted: return NSLocalizedString("you accepted this member", comment: "snd group event chat item")
case .userPendingReview:
return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item")
+1
View File
@@ -25,6 +25,7 @@ extension ChatLike {
case .files: p.files.on(for: groupInfo.membership)
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
case .history: p.history.on
case .support: p.support.on
case .reports: p.reports.on
}
} else {
+3 -3
View File
@@ -74,7 +74,7 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _
return createNotification(
categoryIdentifier: ntfCategoryMessageReceived,
title: title,
body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"),
body: previewMode == .message ? hideSecrets(cItem, isChannel: cInfo.isChannel) : NSLocalizedString("new message", comment: "notification"),
targetContentIdentifier: cInfo.id,
userInfo: ["userId": user.userId],
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
@@ -197,7 +197,7 @@ public func createNotification(
}
// Spec: spec/services/notifications.md#hideSecrets
func hideSecrets(_ cItem: ChatItem) -> String {
func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String {
if let md = cItem.formattedText {
var res = ""
for ft in md {
@@ -213,7 +213,7 @@ func hideSecrets(_ cItem: ChatItem) -> String {
if case let .report(text, reason) = mc {
return String.localizedStringWithFormat(NSLocalizedString("Report: %@", comment: "report in notification"), text.isEmpty ? reason.text : text)
} else {
return cItem.text
return cItem.text(isChannel: isChannel)
}
}
}
+49
View File
@@ -33,6 +33,55 @@ let HighOrLowlight = Color(139, 135, 134, a: 255)
//let FileLight = Color(183, 190, 199, a: 255)
//let FileDark = Color(101, 101, 106, a: 255)
// Create a Display P3 Color from oklch components. H in degrees
public func oklch(_ L: Double, _ C: Double, _ H: Double, alpha: Double = 1.0) -> Color {
let hRad = H * .pi / 180.0
let cosH = cos(hRad)
let sinH = sin(hRad)
func linearP3(C: Double) -> (Double, Double, Double) {
let a = C * cosH
let b = C * sinH
// oklab LMS (Ottosson 2021)
let l_ = L + 0.3963377774 * a + 0.2158037573 * b
let m_ = L - 0.1055613458 * a - 0.0638541728 * b
let s_ = L - 0.0894841775 * a - 1.2914855480 * b
let l = l_ * l_ * l_
let m = m_ * m_ * m_
let s = s_ * s_ * s_
// LMS linear Display P3 (direct, no sRGB clamping)
return (
3.1281105148 * l - 2.2570749853 * m + 0.1293047593 * s,
-1.0911282009 * l + 2.4132668169 * m - 0.3221681599 * s,
-0.0260136845 * l - 0.5080276339 * m + 1.5333166364 * s
)
}
func inGamut(_ r: Double, _ g: Double, _ b: Double) -> Bool {
r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1
}
// linear P3 gamma-encoded P3 (same transfer function as sRGB)
func gammaEncode(_ x: Double) -> Double {
x >= 0.0031308
? 1.055 * pow(min(x, 1.0), 1.0 / 2.4) - 0.055
: 12.92 * max(x, 0)
}
var (r, g, b) = linearP3(C: C)
if !inGamut(r, g, b) {
var lo = 0.0, hi = C
while hi - lo > 1e-5 {
let mid = (lo + hi) / 2
let (mr, mg, mb) = linearP3(C: mid)
if inGamut(mr, mg, mb) { lo = mid; r = mr; g = mg; b = mb }
else { hi = mid }
}
}
return Color(.displayP3, red: gammaEncode(r), green: gammaEncode(g), blue: gammaEncode(b), opacity: alpha)
}
extension Color {
public init(_ argb: Int64) {
let a = Double((argb & 0xFF000000) >> 24) / 255.0
+11
View File
@@ -0,0 +1,11 @@
# Swift mangled symbols (Swift 5+ ABI stable prefix)
_$s*
# ObjC class/metaclass symbols (for NSObject subclasses)
_OBJC_CLASS_$_*
_OBJC_METACLASS_$_*
# C API (SimpleX.h bridging header)
_chat_*
_haskell_init*
_hs_init*
+28 -80
View File
@@ -25,15 +25,9 @@
/* No comment provided by engineer. */
"(this device v%@)" = "(това устройство v%@)";
/* No comment provided by engineer. */
"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Допринеси](https://github.com/simplex-chat/simplex-chat#contribute)";
/* No comment provided by engineer. */
"[Send us email](mailto:chat@simplex.chat)" = "[Изпратете ни имейл](mailto:chat@simplex.chat)";
/* No comment provided by engineer. */
"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Звезда в GitHub](https://github.com/simplex-chat/simplex-chat)";
/* No comment provided by engineer. */
"**Create 1-time link**: to create and share a new invitation link." = "**Добави контакт**: за създаване на нов линк.";
@@ -389,9 +383,6 @@ swipe action */
/* No comment provided by engineer. */
"Active connections" = "Активни връзки";
/* 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." = "Добавете адрес към вашия профил, така че вашите контакти да могат да го споделят с други хора. Актуализацията на профила ще бъде изпратена до вашите контакти.";
/* No comment provided by engineer. */
"Add friends" = "Добави приятели";
@@ -635,9 +626,6 @@ swipe action */
/* No comment provided by engineer. */
"Answer call" = "Отговор на повикване";
/* No comment provided by engineer. */
"Anybody can host servers." = "Протокол и код с отворен код – всеки може да оперира собствени сървъри.";
/* No comment provided by engineer. */
"App build: %@" = "Компилация на приложението: %@";
@@ -873,7 +861,7 @@ marked deleted chat item preview text */
/* No comment provided by engineer. */
"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Български, финландски, тайландски и украински - благодарение на потребителите и [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!";
/* No comment provided by engineer. */
/* chat link info line */
"Business address" = "Бизнес адрес";
/* No comment provided by engineer. */
@@ -888,9 +876,6 @@ marked deleted chat item preview text */
/* No comment provided by engineer. */
"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА).";
/* No comment provided by engineer. */
"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users no spam." = "С използването на SimpleX Chat вие се съгласявате със:\n- изпращане само на легално съдържание в публични групи.\n- уважение към другите потребители – без спам.";
/* No comment provided by engineer. */
"Call already ended!" = "Разговорът вече приключи!";
@@ -1068,7 +1053,8 @@ set passcode view */
/* No comment provided by engineer. */
"Chat will be deleted for you - this cannot be undone!" = "Чатът ще бъде изтрит за вас - това не може да бъде отменено!";
/* chat toolbar */
/* chat feature
chat toolbar */
"Chat with admins" = "Чат с администраторите";
/* No comment provided by engineer. */
@@ -1182,9 +1168,6 @@ set passcode view */
/* No comment provided by engineer. */
"Configure ICE servers" = "Конфигурирай ICE сървъри";
/* No comment provided by engineer. */
"Configure server operators" = "Конфигуриране на сървърни оператори";
/* No comment provided by engineer. */
"Confirm" = "Потвърди";
@@ -1218,7 +1201,8 @@ set passcode view */
/* token status text */
"Confirmed" = "Потвърдено";
/* server test step */
/* relay test step
server test step */
"Connect" = "Свързване";
/* No comment provided by engineer. */
@@ -1317,7 +1301,7 @@ set passcode view */
/* alert title */
"Connection error" = "Грешка при свързване";
/* No comment provided by engineer. */
/* conn error description */
"Connection error (AUTH)" = "Грешка при свързване (AUTH)";
/* chat list item title (it should not be shown */
@@ -1383,18 +1367,18 @@ set passcode view */
/* No comment provided by engineer. */
"Continue" = "Продължи";
/* No comment provided by engineer. */
"Contribute" = "Допринеси";
/* No comment provided by engineer. */
"Copy" = "Копирай";
/* No comment provided by engineer. */
"Core version: v%@" = "Версия на ядрото: v%@";
/* No comment provided by engineer. */
/* alert message */
"Correct name to %@?" = "Поправи име на %@?";
/* No comment provided by engineer. */
"Create" = "Създаване";
/* No comment provided by engineer. */
"Create 1-time link" = "Създаване на еднократна препратка";
@@ -1521,9 +1505,6 @@ set passcode view */
/* time unit */
"days" = "дни";
/* No comment provided by engineer. */
"Decentralized" = "Децентрализиран";
/* message decrypt error item */
"Decryption error" = "Грешка при декриптиране";
@@ -1801,7 +1782,7 @@ chat item action */
/* No comment provided by engineer. */
"Edit group profile" = "Редактирай групов профил";
/* No comment provided by engineer. */
/* alert button */
"Enable" = "Активирай";
/* No comment provided by engineer. */
@@ -1825,9 +1806,6 @@ chat item action */
/* No comment provided by engineer. */
"Enable lock" = "Активирай заключване";
/* No comment provided by engineer. */
"Enable notifications" = "Активирай известията";
/* No comment provided by engineer. */
"Enable periodic notifications?" = "Активирай периодични известия?";
@@ -1963,7 +1941,7 @@ chat item action */
/* No comment provided by engineer. */
"error" = "грешка";
/* No comment provided by engineer. */
/* conn error description */
"Error" = "Грешка при свързване със сървъра";
/* No comment provided by engineer. */
@@ -2211,7 +2189,8 @@ snd error text */
/* No comment provided by engineer. */
"Find chats faster" = "Намирайте чатове по-бързо";
/* server test error */
/* relay test error
server test error */
"Fingerprint in server address does not match certificate." = "Въжможно е пръстовият отпечатък на сертификата в адреса на сървъра да е неправилен";
/* No comment provided by engineer. */
@@ -2304,7 +2283,7 @@ snd error text */
/* No comment provided by engineer. */
"Group invitation is no longer valid, it was removed by sender." = "Груповата покана вече е невалидна, премахната е от подателя.";
/* No comment provided by engineer. */
/* chat link info line */
"Group link" = "Групов линк";
/* No comment provided by engineer. */
@@ -2412,9 +2391,6 @@ snd error text */
/* No comment provided by engineer. */
"Immediately" = "Веднага";
/* No comment provided by engineer. */
"Immune to spam" = "Защитен от спам и злоупотреби";
/* No comment provided by engineer. */
"Import" = "Импортиране";
@@ -2500,7 +2476,7 @@ snd error text */
"Initial role" = "Първоначална роля";
/* 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. */
"Instant" = "Мигновено";
@@ -2517,7 +2493,7 @@ snd error text */
/* No comment provided by engineer. */
"invalid chat data" = "невалидни данни за чат";
/* No comment provided by engineer. */
/* conn error description */
"Invalid connection link" = "Невалиден линк за връзка";
/* invalid chat item */
@@ -2532,7 +2508,7 @@ snd error text */
/* No comment provided by engineer. */
"Invalid migration confirmation" = "Невалидно потвърждение за мигриране";
/* No comment provided by engineer. */
/* alert title */
"Invalid name!" = "Невалидно име!";
/* No comment provided by engineer. */
@@ -2823,9 +2799,6 @@ snd error text */
/* No comment provided by engineer. */
"Migrate device" = "Мигрирай устройството";
/* No comment provided by engineer. */
"Migrate from another device" = "Мигриране от друго устройство";
/* No comment provided by engineer. */
"Migrate here" = "Мигрирай тук";
@@ -3000,9 +2973,6 @@ snd error text */
/* copied message info in history */
"no text" = "няма текст";
/* No comment provided by engineer. */
"No user identifiers." = "Първата платформа без никакви потребителски идентификатори – поверителна по дизайн.";
/* No comment provided by engineer. */
"Not compatible!" = "Несъвместим!";
@@ -3038,7 +3008,7 @@ alert button
new chat action */
"Ok" = "Ок";
/* No comment provided by engineer. */
/* alert button */
"OK" = "ОК";
/* No comment provided by engineer. */
@@ -3101,7 +3071,8 @@ new chat action */
/* No comment provided by engineer. */
"Only your contact can send voice messages." = "Само вашият контакт може да изпраща гласови съобщения.";
/* alert action */
/* alert action
alert button */
"Open" = "Отвори";
/* new chat action */
@@ -3248,9 +3219,6 @@ new chat action */
/* No comment provided by engineer. */
"Privacy & security" = "Поверителност и сигурност";
/* No comment provided by engineer. */
"Privacy redefined" = "Поверителността преосмислена";
/* No comment provided by engineer. */
"Private filenames" = "Поверителни имена на файлове";
@@ -3269,9 +3237,6 @@ new chat action */
/* No comment provided by engineer. */
"Profile password" = "Профилна парола";
/* alert message */
"Profile update will be sent to your contacts." = "Актуализацията на профила ще бъде изпратена до вашите контакти.";
/* No comment provided by engineer. */
"Prohibit audio/video calls." = "Забрани аудио/видео разговорите.";
@@ -3336,16 +3301,10 @@ new chat action */
"Read more" = "Прочетете още";
/* 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 our GitHub repository." = "Прочетете повече в нашето GitHub хранилище.";
/* 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 User Guide." = "Прочетете повече в Ръководство за потребителя.";
/* No comment provided by engineer. */
"Receipts are disabled" = "Потвърждениeто за доставка е деактивирано";
@@ -3780,18 +3739,12 @@ chat item action */
/* No comment provided by engineer. */
"Share address" = "Сподели адрес";
/* alert title */
"Share address with contacts?" = "Сподели адреса с контактите?";
/* No comment provided by engineer. */
"Share link" = "Сподели линк";
/* No comment provided by engineer. */
"Share this 1-time invite link" = "Сподели този еднократен линк за връзка";
/* No comment provided by engineer. */
"Share with contacts" = "Сподели с контактите";
/* No comment provided by engineer. */
"Show calls in phone history" = "Показване на обажданията в хронологията на телефона";
@@ -3879,6 +3832,9 @@ chat item action */
/* chat item text */
"standard end-to-end encryption" = "стандартно криптиране от край до край";
/* No comment provided by engineer. */
"Star on GitHub" = "Звезда в GitHub";
/* No comment provided by engineer. */
"Start chat" = "Започни чат";
@@ -3975,7 +3931,8 @@ chat item action */
/* No comment provided by engineer. */
"TCP_KEEPINTVL" = "TCP_KEEPINTVL";
/* server test failure */
/* relay test failure
server test failure */
"Test failed at step %@." = "Тестът е неуспешен на стъпка %@.";
/* No comment provided by engineer. */
@@ -4017,9 +3974,6 @@ chat item action */
/* No comment provided by engineer. */
"The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Криптирането работи и новото споразумение за криптиране не е необходимо. Това може да доведе до грешки при свързване!";
/* No comment provided by engineer. */
"The future of messaging" = "Ново поколение поверителни съобщения";
/* No comment provided by engineer. */
"The hash of the previous message is different." = "Хешът на предишното съобщение е различен.";
@@ -4248,9 +4202,6 @@ chat item action */
/* No comment provided by engineer. */
"Use .onion hosts" = "Използвай .onion хостове";
/* No comment provided by engineer. */
"Use chat" = "Използвай чата";
/* new chat action */
"Use current profile" = "Използвай текущия профил";
@@ -4554,9 +4505,6 @@ chat item action */
/* No comment provided by engineer. */
"You could not be verified; please try again." = "Не можахте да бъдете потвърдени; Моля, опитайте отново.";
/* No comment provided by engineer. */
"You decide who can connect." = "Хората могат да се свържат с вас само чрез ликовете, които споделяте.";
/* new chat sheet title */
"You have already requested connection!\nRepeat connection request?" = "Вече сте направили заявката за връзка!\nИзпрати отново заявката за свързване?";
+69 -80
View File
@@ -25,15 +25,9 @@
/* No comment provided by engineer. */
"(this device v%@)" = "(toto zařízení v%@)";
/* No comment provided by engineer. */
"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Přispějte](https://github.com/simplex-chat/simplex-chat#contribute)";
/* No comment provided by engineer. */
"[Send us email](mailto:chat@simplex.chat)" = "[Pošlete nám e-mail](mailto:chat@simplex.chat)";
/* No comment provided by engineer. */
"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Hvězda na GitHubu](https://github.com/simplex-chat/simplex-chat)";
/* No comment provided by engineer. */
"**Create 1-time link**: to create and share a new invitation link." = "**Vytvořit jednorázový odkaz**: pro vytvoření a sdílení nové pozvánky.";
@@ -196,6 +190,9 @@
/* No comment provided by engineer. */
"%lld file(s) with total size of %@" = "%lld soubor(y) s celkovou velikostí %@";
/* No comment provided by engineer. */
"%lld group events" = "%lld událostí skupiny";
/* No comment provided by engineer. */
"%lld members" = "%lld členové";
@@ -305,7 +302,7 @@ time interval */
"A new random profile will be shared." = "Nový náhodný profil bude sdílen.";
/* No comment provided by engineer. */
"A separate TCP connection will be used **for each chat profile you have in the app**." = "Samostatné připojení TCP bude použito **pro každý chat profil, který máte v aplikaci**.";
"A separate TCP connection will be used **for each chat profile you have in the app**." = "Samostatné připojení TCP bude použito **pro každý profil chatu, který máte v aplikaci**.";
/* No comment provided by engineer. */
"A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "**pro každý kontakt a člena skupiny** bude použito samostatné připojení TCP.\n**Upozornění**: Pokud máte mnoho připojení, spotřeba baterie a provozu může být podstatně vyšší a některá připojení mohou selhat.";
@@ -371,9 +368,6 @@ swipe action */
/* No comment provided by engineer. */
"Active connections" = "Aktivní spojení";
/* No comment provided by engineer. */
"Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Přidejte adresu do svého profilu, aby ji vaše kontakty mohly sdílet s dalšími lidmi. Aktualizace profilu bude zaslána vašim kontaktům.";
/* No comment provided by engineer. */
"Add friends" = "Přidat přátele";
@@ -453,7 +447,7 @@ swipe action */
"All chats and messages will be deleted - this cannot be undone!" = "Všechny chaty a zprávy budou smazány tuto akci nelze vrátit zpět!";
/* alert message */
"All chats will be removed from the list %@, and the list deleted." = "Všechny chaty budou odstraněny ze seznamu %@ a seznam bude odstraněn.";
"All chats will be removed from the list %@, and the list deleted." = "Všechny chaty budou odstraněny ze seznamu %@ a seznam bude smazán.";
/* No comment provided by engineer. */
"All data is erased when it is entered." = "Všechna data se při zadání vymažou.";
@@ -570,7 +564,7 @@ swipe action */
"Always use relay" = "Spojení přes relé";
/* No comment provided by engineer. */
"An empty chat profile with the provided name is created, and the app opens as usual." = "Vytvořit prázdný chat profil se zadaným názvem a otevřít aplikaci jako obvykle.";
"An empty chat profile with the provided name is created, and the app opens as usual." = "Vytvořit prázdný profil chatu se zadaným názvem a otevřít aplikaci jako obvykle.";
/* report reason */
"Another reason" = "Jiný důvod";
@@ -578,9 +572,6 @@ swipe action */
/* No comment provided by engineer. */
"Answer call" = "Přijmout hovor";
/* No comment provided by engineer. */
"Anybody can host servers." = "Servery může provozovat kdokoli.";
/* No comment provided by engineer. */
"App build: %@" = "Sestavení aplikace: %@";
@@ -680,6 +671,12 @@ swipe action */
/* No comment provided by engineer. */
"Bad message ID" = "Špatné ID zprávy";
/* No comment provided by engineer. */
"Be free in your network." = "Buďte svobodní ve své síti.";
/* No comment provided by engineer. */
"Because we destroyed the power to know who you are. So that your power can never be taken." = "Protože jsme zničili sílu vědět, kdo jste. Aby vám vaši moc nikdo nemohl vzít.";
/* No comment provided by engineer. */
"Better calls" = "Lepší volání";
@@ -743,11 +740,11 @@ swipe action */
/* No comment provided by engineer. */
"Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulharský, finský, thajský a ukrajinský - díky uživatelům a [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!";
/* No comment provided by engineer. */
/* chat link info line */
"Business address" = "Obchodní adresa";
/* No comment provided by engineer. */
"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Podle chat profilu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA).";
"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Podle profilu chatu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA).";
/* No comment provided by engineer. */
"Call already ended!" = "Hovor již skončil!";
@@ -821,7 +818,7 @@ new chat action */
"Change automatic message deletion?" = "Změnit automatické mazání zpráv?";
/* authentication reason */
"Change chat profiles" = "Změnit chat profily";
"Change chat profiles" = "Změnit profily chatu";
/* No comment provided by engineer. */
"Change database passphrase?" = "Změnit přístupovou frázi databáze?";
@@ -959,7 +956,8 @@ set passcode view */
/* No comment provided by engineer. */
"Confirm upload" = "Potvrdit nahrání";
/* server test step */
/* relay test step
server test step */
"Connect" = "Připojit";
/* No comment provided by engineer. */
@@ -1013,7 +1011,7 @@ set passcode view */
/* alert title */
"Connection error" = "Chyba připojení";
/* No comment provided by engineer. */
/* conn error description */
"Connection error (AUTH)" = "Chyba spojení (AUTH)";
/* chat list item title (it should not be shown */
@@ -1061,15 +1059,15 @@ set passcode view */
/* No comment provided by engineer. */
"Continue" = "Pokračovat";
/* No comment provided by engineer. */
"Contribute" = "Přispějte";
/* No comment provided by engineer. */
"Copy" = "Kopírovat";
/* No comment provided by engineer. */
"Core version: v%@" = "Verze jádra: v%@";
/* No comment provided by engineer. */
"Create" = "Vytvořit";
/* server test step */
"Create file" = "Vytvořit soubor";
@@ -1175,9 +1173,6 @@ set passcode view */
/* time unit */
"days" = "dní";
/* No comment provided by engineer. */
"Decentralized" = "Decentralizované";
/* message decrypt error item */
"Decryption error" = "Chyba dešifrování";
@@ -1208,10 +1203,10 @@ swipe action */
"Delete all files" = "Odstranit všechny soubory";
/* No comment provided by engineer. */
"Delete chat profile" = "Smazat chat profil";
"Delete chat profile" = "Smazat profil chatu";
/* No comment provided by engineer. */
"Delete chat profile?" = "Smazat chat profil?";
"Delete chat profile?" = "Smazat profil chatu?";
/* No comment provided by engineer. */
"Delete connection" = "Smazat připojení";
@@ -1412,7 +1407,7 @@ alert button */
/* No comment provided by engineer. */
"Edit group profile" = "Upravit profil skupiny";
/* No comment provided by engineer. */
/* alert button */
"Enable" = "Zapnout";
/* No comment provided by engineer. */
@@ -1430,9 +1425,6 @@ alert button */
/* No comment provided by engineer. */
"Enable lock" = "Povolit zámek";
/* No comment provided by engineer. */
"Enable notifications" = "Povolit upozornění";
/* No comment provided by engineer. */
"Enable periodic notifications?" = "Povolit pravidelná oznámení?";
@@ -1544,7 +1536,7 @@ alert button */
/* No comment provided by engineer. */
"error" = "chyba";
/* No comment provided by engineer. */
/* conn error description */
"Error" = "Chyba";
/* No comment provided by engineer. */
@@ -1753,7 +1745,8 @@ snd error text */
/* No comment provided by engineer. */
"Find chats faster" = "Najděte chaty rychleji";
/* server test error */
/* relay test error
server test error */
"Fingerprint in server address does not match certificate." = "Otisk certifikátu v adrese serveru neodpovídá.";
/* No comment provided by engineer. */
@@ -1819,7 +1812,7 @@ snd error text */
/* No comment provided by engineer. */
"Group invitation is no longer valid, it was removed by sender." = "Skupinová pozvánka již není platná, byla odstraněna odesílatelem.";
/* No comment provided by engineer. */
/* chat link info line */
"Group link" = "Odkaz na skupinu";
/* No comment provided by engineer. */
@@ -1859,7 +1852,7 @@ snd error text */
"Hidden" = "Skryté";
/* No comment provided by engineer. */
"Hidden chat profiles" = "Skryté chat profily";
"Hidden chat profiles" = "Skryté profily chatu";
/* No comment provided by engineer. */
"Hidden profile password" = "Hesla skrytých profilů";
@@ -1921,9 +1914,6 @@ snd error text */
/* No comment provided by engineer. */
"Immediately" = "Ihned";
/* No comment provided by engineer. */
"Immune to spam" = "Odolná vůči spamu a zneužití";
/* No comment provided by engineer. */
"Import" = "Import";
@@ -1988,7 +1978,7 @@ snd error text */
"Initial role" = "Počáteční role";
/* No comment provided by engineer. */
"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Nainstalujte [SimpleX Chat pro terminál](https://github.com/simplex-chat/simplex-chat)";
"Install SimpleX Chat for terminal" = "Nainstalujte SimpleX Chat pro terminál";
/* No comment provided by engineer. */
"Instant" = "Okamžitě";
@@ -2005,7 +1995,7 @@ snd error text */
/* No comment provided by engineer. */
"invalid chat data" = "neplatná chat data";
/* No comment provided by engineer. */
/* conn error description */
"Invalid connection link" = "Neplatný odkaz na spojení";
/* invalid chat item */
@@ -2291,7 +2281,7 @@ snd error text */
"Most likely this connection is deleted." = "Pravděpodobně je toto spojení smazáno.";
/* No comment provided by engineer. */
"Multiple chat profiles" = "Více chatovacích profilů";
"Multiple chat profiles" = "Více profilů chatu";
/* notification label action */
"Mute" = "Ztlumit";
@@ -2390,7 +2380,10 @@ snd error text */
"no text" = "žádný text";
/* No comment provided by engineer. */
"No user identifiers." = "Bez uživatelských identifikátorů.";
"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." = "Nikdo nesledoval vaše konverzace. Nikdo nevytvořil mapu, kde jste byli. Soukromí nikdy nebylo funkcí - byl to způsob života.";
/* 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." = "Nejde o to mít lepší zámek na dveřích někoho jiného. Ani o to mít nájemce, který respektuje vaše soukromí, ale vede evidenci všech vašich návštěvníků. Nejste host. Jste doma. Ani král k vám nemůže vstoupit - jste suverén.";
/* No comment provided by engineer. */
"Notifications" = "Oznámení";
@@ -2484,7 +2477,8 @@ new chat action */
/* No comment provided by engineer. */
"Only your contact can send voice messages." = "Hlasové zprávy může odesílat pouze váš kontakt.";
/* alert action */
/* alert action
alert button */
"Open" = "Otevřít";
/* new chat action */
@@ -2586,9 +2580,6 @@ new chat action */
/* No comment provided by engineer. */
"Privacy & security" = "Ochrana osobních údajů a zabezpečení";
/* No comment provided by engineer. */
"Privacy redefined" = "Nové vymezení soukromí";
/* No comment provided by engineer. */
"Private filenames" = "Soukromé názvy souborů";
@@ -2601,9 +2592,6 @@ new chat action */
/* No comment provided by engineer. */
"Profile password" = "Heslo profilu";
/* alert message */
"Profile update will be sent to your contacts." = "Aktualizace profilu bude zaslána vašim kontaktům.";
/* No comment provided by engineer. */
"Prohibit audio/video calls." = "Zákaz audio/video hovorů.";
@@ -2632,7 +2620,7 @@ new chat action */
"Protect app screen" = "Ochrana obrazovky aplikace";
/* No comment provided by engineer. */
"Protect your chat profiles with a password!" = "Chraňte své chat profily heslem!";
"Protect your chat profiles with a password!" = "Chraňte své profily chatu pomocí hesla!";
/* No comment provided by engineer. */
"Protocol timeout" = "Časový limit protokolu";
@@ -2656,13 +2644,10 @@ new chat action */
"Read more" = "Přečíst více";
/* 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)." = "Další informace naleznete v [Uživatelské příručce](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).";
"Read more in our GitHub repository." = "Přečtěte si více v našem GitHub repozitáři.";
/* No comment provided by engineer. */
"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Přečtěte si více v [Uživatelské příručce](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)." = "Přečtěte si více v našem [GitHub repozitáři](https://github.com/simplex-chat/simplex-chat#readme).";
"Read more in User Guide." = "Více informací v průvodci uživatele.";
/* No comment provided by engineer. */
"Receipts are disabled" = "Informace o dodání jsou zakázány";
@@ -2775,7 +2760,7 @@ swipe action */
"Reset to defaults" = "Obnovení výchozího nastavení";
/* No comment provided by engineer. */
"Restart the app to create a new chat profile" = "Restartujte aplikaci pro vytvoření nového chat profilu";
"Restart the app to create a new chat profile" = "Restartujte aplikaci pro vytvoření nového profilu chatu";
/* No comment provided by engineer. */
"Restart the app to use imported chat database" = "Restartujte aplikaci pro použití importované databáze chatu";
@@ -2944,7 +2929,7 @@ chat item action */
"Sender may have deleted the connection request." = "Odesílatel možná smazal požadavek připojení.";
/* No comment provided by engineer. */
"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "Odesílání potvrzení o doručení bude povoleno pro všechny kontakty ve všech viditelných chat profilech.";
"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "Odesílání potvrzení o doručení bude povoleno pro všechny kontakty ve všech viditelných profilech chatu.";
/* No comment provided by engineer. */
"Sending delivery receipts will be enabled for all contacts." = "Odesílání potvrzení o doručení bude povoleno pro všechny kontakty.";
@@ -3028,15 +3013,9 @@ chat item action */
/* No comment provided by engineer. */
"Share address" = "Sdílet adresu";
/* alert title */
"Share address with contacts?" = "Sdílet adresu s kontakty?";
/* No comment provided by engineer. */
"Share link" = "Sdílet odkaz";
/* No comment provided by engineer. */
"Share with contacts" = "Sdílet s kontakty";
/* No comment provided by engineer. */
"Show calls in phone history" = "Ukaž hovory v historii telefonu";
@@ -3106,6 +3085,9 @@ chat item action */
/* notification title */
"Somebody" = "Někdo";
/* No comment provided by engineer. */
"Star on GitHub" = "Hvězda na GitHubu";
/* No comment provided by engineer. */
"Start chat" = "Začít chat";
@@ -3184,7 +3166,8 @@ chat item action */
/* No comment provided by engineer. */
"TCP_KEEPINTVL" = "TCP_KEEPINTVL";
/* server test failure */
/* relay test failure
server test failure */
"Test failed at step %@." = "Test selhal v kroku %@.";
/* No comment provided by engineer. */
@@ -3223,9 +3206,6 @@ chat item action */
/* No comment provided by engineer. */
"The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Šifrování funguje a nové povolení šifrování není vyžadováno. To může vyvolat chybu v připojení!";
/* No comment provided by engineer. */
"The future of messaging" = "Nová generace soukromých zpráv";
/* No comment provided by engineer. */
"The hash of the previous message is different." = "Hash předchozí zprávy se liší.";
@@ -3241,6 +3221,9 @@ chat item action */
/* No comment provided by engineer. */
"The old database was not removed during the migration, it can be deleted." = "Stará databáze nebyla během přenášení odstraněna, lze ji smazat.";
/* No comment provided by engineer. */
"The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it." = "Nejstarší lidská svoboda - mluvit s druhým člověkem, aniž by byl sledován - postavena na infrastruktuře, která ji nemůže zradit.";
/* No comment provided by engineer. */
"The second tick we missed! ✅" = "Druhé zaškrtnutí jsme přehlédli! ✅";
@@ -3248,7 +3231,13 @@ chat item action */
"The sender will NOT be notified" = "Odesílatel NEBUDE informován";
/* No comment provided by engineer. */
"The servers for new connections of your current chat profile **%@**." = "Servery pro nová připojení vašeho aktuálního chat profilu **%@**.";
"The servers for new connections of your current chat profile **%@**." = "Servery pro nová připojení vašeho aktuálního profilu chatu **%@**.";
/* No comment provided by engineer. */
"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." = "Pak jsme se přesunuli na internet a každá platforma chtěla o vás něco vědět - vaše jméno, vaše číslo, vaše přátele. Smířili jsme se s tím, že cenou za komunikaci s ostatními je dát někomu vědět, s kým mluvíme. Každá generace, lidská i technická, to tak měla - telefon, e-mail, komunikátory, sociální sítě. Zdálo se, že je to jediný možný způsob.";
/* No comment provided by engineer. */
"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." = "Existuje i jiný způsob. Síť bez telefonních čísel. Bez uživatelských jmen. Bez účtů. Bez jakékoli uživatelské identity. Síť, která spojuje lidi a přenáší šifrované zprávy, aniž by bylo známo, kdo je připojen.";
/* No comment provided by engineer. */
"These settings are for your current profile **%@**." = "Toto nastavení je pro váš aktuální profil **%@**.";
@@ -3275,7 +3264,7 @@ chat item action */
"This group no longer exists." = "Tato skupina již neexistuje.";
/* No comment provided by engineer. */
"This setting applies to messages in your current chat profile **%@**." = "Toto nastavení platí pro zprávy ve vašem aktuálním chat profilu **%@**.";
"This setting applies to messages in your current chat profile **%@**." = "Toto nastavení platí pro zprávy ve vašem aktuálním profilu chatu **%@**.";
/* No comment provided by engineer. */
"To ask any questions and to receive updates:" = "Chcete-li položit jakékoli dotazy a dostávat aktuality:";
@@ -3299,7 +3288,7 @@ chat item action */
"To record voice message please grant permission to use Microphone." = "Chcete-li nahrávat hlasové zprávy, udělte povolení k použití mikrofonu.";
/* No comment provided by engineer. */
"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Chcete-li odhalit svůj skrytý profil, zadejte celé heslo do vyhledávacího pole na stránce **Chat profily**.";
"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Chcete-li odhalit svůj skrytý profil, zadejte celé heslo do vyhledávacího pole na stránce **Profily chatu**.";
/* No comment provided by engineer. */
"To support instant push notifications the chat database has to be migrated." = "Pro podporu doručování okamžitých upozornění musí být přenesena chat databáze.";
@@ -3332,7 +3321,7 @@ chat item action */
"Unhide" = "Odkrýt";
/* No comment provided by engineer. */
"Unhide chat profile" = "Odkrýt chat profil";
"Unhide chat profile" = "Odkrýt profil chatu";
/* No comment provided by engineer. */
"Unhide profile" = "Odkrýt profil";
@@ -3394,9 +3383,6 @@ chat item action */
/* No comment provided by engineer. */
"Use .onion hosts" = "Použít hostitele .onion";
/* No comment provided by engineer. */
"Use chat" = "Použijte chat";
/* new chat action */
"Use current profile" = "Použít aktuální profil";
@@ -3569,7 +3555,7 @@ chat item action */
"You can set lock screen notification preview via settings." = "Náhled oznámení na zamykací obrazovce můžete změnit v nastavení.";
/* No comment provided by engineer. */
"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Můžete sdílet odkaz nebo QR kód - ke skupině se bude moci připojit kdokoli. O členy skupiny nepřijdete, pokud ji později odstraníte.";
"You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Můžete sdílet odkaz nebo QR kód - ke skupině se bude moci připojit kdokoli. O členy skupiny nepřijdete, pokud odkaz později smažete.";
/* No comment provided by engineer. */
"You can share this address with your contacts to let them connect with **%@**." = "Tuto adresu můžete sdílet s vašimi kontakty, abyse se mohli spojit s **%@**.";
@@ -3601,9 +3587,6 @@ chat item action */
/* No comment provided by engineer. */
"You could not be verified; please try again." = "Nemohli jste být ověřeni; Zkuste to prosím znovu.";
/* No comment provided by engineer. */
"You decide who can connect." = "Lidé se s vámi mohou spojit pouze prostřednictvím odkazu, který sdílíte.";
/* No comment provided by engineer. */
"You have to enter passphrase every time the app starts - it is not stored on the device." = "Musíte zadat přístupovou frázi při každém spuštění aplikace - není uložena v zařízení.";
@@ -3640,6 +3623,9 @@ chat item action */
/* chat list item description */
"you shared one-time link incognito" = "sdíleli jste jednorázový odkaz inkognito";
/* No comment provided by engineer. */
"You were born without an account" = "Narodili jste se bez účtu.";
/* No comment provided by engineer. */
"You will be connected to group when the group host's device is online, please wait or check later!" = "Ke skupině budete připojeni, až bude zařízení hostitele skupiny online, vyčkejte prosím nebo se podívejte později!";
@@ -3680,7 +3666,7 @@ chat item action */
"Your chat database is not encrypted - set passphrase to encrypt it." = "Vaše chat databáze není šifrována nastavte přístupovou frázi pro její šifrování.";
/* No comment provided by engineer. */
"Your chat profiles" = "Vaše chat profily";
"Your chat profiles" = "Vaše profily chatu";
/* No comment provided by engineer. */
"Your contact sent a file that is larger than currently supported maximum size (%@)." = "Kontakt odeslal soubor, který je větší než aktuálně podporovaná maximální velikost (%@).";
@@ -3691,6 +3677,9 @@ chat item action */
/* No comment provided by engineer. */
"Your contacts will remain connected." = "Vaše kontakty zůstanou připojeny.";
/* No comment provided by engineer. */
"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." = "Vaše konverzace patří vám, jako tomu bylo vždy před internetem. Síť není místo, které navštěvujete. Je to místo, které vytváříte a vlastníte. A nikdo vám ho nemůže vzít, ať už je soukromé, nebo veřejné.";
/* No comment provided by engineer. */
"Your current chat database will be DELETED and REPLACED with the imported one." = "Vaše aktuální chat databáze bude ODSTRANĚNA a NAHRAZENA importovanou.";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -62
View File
@@ -13,15 +13,9 @@
/* No comment provided by engineer. */
"!1 colored!" = "!1 värillinen!";
/* No comment provided by engineer. */
"[Contribute](https://github.com/simplex-chat/simplex-chat#contribute)" = "[Osallistu](https://github.com/simplex-chat/simplex-chat#contribute)";
/* No comment provided by engineer. */
"[Send us email](mailto:chat@simplex.chat)" = "[Lähetä meille sähköpostia](mailto:chat@simplex.chat)";
/* No comment provided by engineer. */
"[Star on GitHub](https://github.com/simplex-chat/simplex-chat)" = "[Tähti GitHubissa](https://github.com/simplex-chat/simplex-chat)";
/* No comment provided by engineer. */
"**e2e encrypted** audio call" = "**e2e-salattu** äänipuhelu";
@@ -257,9 +251,6 @@ swipe action */
/* call status */
"accepted call" = "hyväksytty puhelu";
/* 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." = "Lisää osoite profiiliisi, jotta kontaktisi voivat jakaa sen muiden kanssa. Profiilipäivitys lähetetään kontakteillesi.";
/* No comment provided by engineer. */
"Add profile" = "Lisää profiili";
@@ -386,9 +377,6 @@ swipe action */
/* No comment provided by engineer. */
"Answer call" = "Vastaa puheluun";
/* No comment provided by engineer. */
"Anybody can host servers." = "Avoimen lähdekoodin protokolla ja koodi - kuka tahansa voi käyttää palvelimia.";
/* No comment provided by engineer. */
"App build: %@" = "Sovellusversio: %@";
@@ -650,7 +638,8 @@ set passcode view */
/* No comment provided by engineer. */
"Confirm password" = "Vahvista salasana";
/* server test step */
/* relay test step
server test step */
"Connect" = "Yhdistä";
/* No comment provided by engineer. */
@@ -698,7 +687,7 @@ set passcode view */
/* alert title */
"Connection error" = "Yhteysvirhe";
/* No comment provided by engineer. */
/* conn error description */
"Connection error (AUTH)" = "Yhteysvirhe (AUTH)";
/* chat list item title (it should not be shown */
@@ -746,15 +735,15 @@ set passcode view */
/* No comment provided by engineer. */
"Continue" = "Jatka";
/* No comment provided by engineer. */
"Contribute" = "Osallistu";
/* No comment provided by engineer. */
"Copy" = "Kopioi";
/* No comment provided by engineer. */
"Core version: v%@" = "Ydinversio: v%@";
/* No comment provided by engineer. */
"Create" = "Luo";
/* server test step */
"Create file" = "Luo tiedosto";
@@ -860,9 +849,6 @@ set passcode view */
/* time unit */
"days" = "päivää";
/* No comment provided by engineer. */
"Decentralized" = "Hajautettu";
/* message decrypt error item */
"Decryption error" = "Salauksen purkuvirhe";
@@ -1097,7 +1083,7 @@ alert button */
/* No comment provided by engineer. */
"Edit group profile" = "Muokkaa ryhmäprofiilia";
/* No comment provided by engineer. */
/* alert button */
"Enable" = "Salli";
/* No comment provided by engineer. */
@@ -1115,9 +1101,6 @@ alert button */
/* No comment provided by engineer. */
"Enable lock" = "Ota lukitus käyttöön";
/* No comment provided by engineer. */
"Enable notifications" = "Salli ilmoitukset";
/* No comment provided by engineer. */
"Enable periodic notifications?" = "Salli säännölliset ilmoitukset?";
@@ -1226,7 +1209,7 @@ alert button */
/* No comment provided by engineer. */
"error" = "virhe";
/* No comment provided by engineer. */
/* conn error description */
"Error" = "Virhe";
/* No comment provided by engineer. */
@@ -1429,7 +1412,8 @@ snd error text */
/* No comment provided by engineer. */
"Find chats faster" = "Löydä keskustelut nopeammin";
/* server test error */
/* relay test error
server test error */
"Fingerprint in server address does not match certificate." = "Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen";
/* No comment provided by engineer. */
@@ -1495,7 +1479,7 @@ snd error text */
/* No comment provided by engineer. */
"Group invitation is no longer valid, it was removed by sender." = "Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen.";
/* No comment provided by engineer. */
/* chat link info line */
"Group link" = "Ryhmälinkki";
/* No comment provided by engineer. */
@@ -1597,9 +1581,6 @@ snd error text */
/* No comment provided by engineer. */
"Immediately" = "Heti";
/* No comment provided by engineer. */
"Immune to spam" = "Immuuni roskapostille ja väärinkäytöksille";
/* No comment provided by engineer. */
"Import" = "Tuo";
@@ -1664,7 +1645,7 @@ snd error text */
"Initial role" = "Alkuperäinen rooli";
/* No comment provided by engineer. */
"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "Asenna [SimpleX Chat terminaalille](https://github.com/simplex-chat/simplex-chat)";
"Install SimpleX Chat for terminal" = "Asenna SimpleX Chat terminaalille";
/* No comment provided by engineer. */
"Instant" = "Heti";
@@ -1681,7 +1662,7 @@ snd error text */
/* No comment provided by engineer. */
"invalid chat data" = "virheelliset keskustelu-tiedot";
/* No comment provided by engineer. */
/* conn error description */
"Invalid connection link" = "Virheellinen yhteyslinkki";
/* invalid chat item */
@@ -2062,9 +2043,6 @@ snd error text */
/* copied message info in history */
"no text" = "ei tekstiä";
/* No comment provided by engineer. */
"No user identifiers." = "Ensimmäinen alusta ilman käyttäjätunnisteita suunniteltu yksityiseksi.";
/* No comment provided by engineer. */
"Notifications" = "Ilmoitukset";
@@ -2256,9 +2234,6 @@ new chat action */
/* No comment provided by engineer. */
"Privacy & security" = "Yksityisyys ja turvallisuus";
/* No comment provided by engineer. */
"Privacy redefined" = "Yksityisyys uudelleen määritettynä";
/* No comment provided by engineer. */
"Private filenames" = "Yksityiset tiedostonimet";
@@ -2271,9 +2246,6 @@ new chat action */
/* No comment provided by engineer. */
"Profile password" = "Profiilin salasana";
/* alert message */
"Profile update will be sent to your contacts." = "Profiilipäivitys lähetetään kontakteillesi.";
/* No comment provided by engineer. */
"Prohibit audio/video calls." = "Estä ääni- ja videopuhelut.";
@@ -2326,13 +2298,10 @@ new chat action */
"Read more" = "Lue lisää";
/* 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)." = "Lue lisää [Käyttöoppaasta](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).";
"Read more in our GitHub repository." = "Lue lisää GitHub-arkistosta.";
/* No comment provided by engineer. */
"Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends)." = "Lue lisää [Käyttöoppaasta](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)." = "Lue lisää [GitHub-arkistosta](https://github.com/simplex-chat/simplex-chat#readme).";
"Read more in User Guide." = "Lue lisää Käyttöoppaasta.";
/* No comment provided by engineer. */
"Receipts are disabled" = "Kuittaukset pois käytöstä";
@@ -2695,15 +2664,9 @@ chat item action */
/* No comment provided by engineer. */
"Share address" = "Jaa osoite";
/* alert title */
"Share address with contacts?" = "Jaa osoite kontakteille?";
/* No comment provided by engineer. */
"Share link" = "Jaa linkki";
/* No comment provided by engineer. */
"Share with contacts" = "Jaa kontaktien kanssa";
/* No comment provided by engineer. */
"Show calls in phone history" = "Näytä puhelut puhelinhistoriassa";
@@ -2770,6 +2733,9 @@ chat item action */
/* notification title */
"Somebody" = "Joku";
/* No comment provided by engineer. */
"Star on GitHub" = "Tähti GitHubissa";
/* No comment provided by engineer. */
"Start chat" = "Aloita keskustelu";
@@ -2848,7 +2814,8 @@ chat item action */
/* No comment provided by engineer. */
"TCP_KEEPINTVL" = "TCP_KEEPINTVL";
/* server test failure */
/* relay test failure
server test failure */
"Test failed at step %@." = "Testi epäonnistui vaiheessa %@.";
/* No comment provided by engineer. */
@@ -2887,9 +2854,6 @@ chat item action */
/* No comment provided by engineer. */
"The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin!";
/* No comment provided by engineer. */
"The future of messaging" = "Seuraavan sukupolven yksityisviestit";
/* No comment provided by engineer. */
"The hash of the previous message is different." = "Edellisen viestin tarkiste on erilainen.";
@@ -3055,9 +3019,6 @@ chat item action */
/* No comment provided by engineer. */
"Use .onion hosts" = "Käytä .onion-isäntiä";
/* No comment provided by engineer. */
"Use chat" = "Käytä chattia";
/* new chat action */
"Use current profile" = "Käytä nykyistä profiilia";
@@ -3262,9 +3223,6 @@ chat item action */
/* No comment provided by engineer. */
"You could not be verified; please try again." = "Sinua ei voitu todentaa; yritä uudelleen.";
/* No comment provided by engineer. */
"You decide who can connect." = "Kimin bağlanabileceğine siz karar verirsiniz.";
/* No comment provided by engineer. */
"You have to enter passphrase every time the app starts - it is not stored on the device." = "Sinun on annettava tunnuslause aina, kun sovellus käynnistyy - sitä ei tallenneta laitteeseen.";

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