mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-13 21:33:41 +00:00
Merge branch 'master' into ep/smp-server-pages
This commit is contained in:
+136
-13
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -69,3 +69,7 @@ Libraries/
|
||||
Shared/MyPlayground.playground/*
|
||||
|
||||
testpush.sh
|
||||
|
||||
# Local build config and generated assets
|
||||
Local.xcconfig
|
||||
Shared/SimpleXAssets.xcassets/*.imageset
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
#include? "Local.xcconfig"
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
#include? "Local.xcconfig"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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." = "Выбранные настройки чата запрещают это сообщение.";
|
||||
|
||||
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "07434ae88cbf078ce3d27c91c1f605836aaebff0e0cef5f25317795151c77db1",
|
||||
"originHash" : "60aeecb7917535a5e44ade0dbb5411ab112a959283e565a04c212c8af4e7dee9",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "codescanner",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*
|
||||
@@ -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Изпрати отново заявката за свързване?";
|
||||
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user