Compare commits
1 Commits
v5.3.1-fdr
...
ep/rfc-use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0366afbcce |
151
.github/workflows/build.yml
vendored
151
.github/workflows/build.yml
vendored
@@ -8,7 +8,6 @@ on:
|
||||
- users
|
||||
tags:
|
||||
- "v*"
|
||||
- "!*-fdroid"
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -53,19 +52,15 @@ jobs:
|
||||
- os: ubuntu-20.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
|
||||
- os: ubuntu-22.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
|
||||
- os: macos-latest
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
|
||||
- os: windows-latest
|
||||
cache_path: C:/cabal
|
||||
asset_name: simplex-chat-windows-x86-64
|
||||
desktop_asset_name: simplex-desktop-windows-x86_64.msi
|
||||
steps:
|
||||
- name: Configure pagefile (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -104,10 +99,6 @@ jobs:
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Install AppImage dependencies
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
run: sudo apt install -y desktop-file-utils
|
||||
|
||||
- name: Install pkg-config for Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: brew install pkg-config
|
||||
@@ -120,136 +111,23 @@ jobs:
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Unix build CLI
|
||||
id: unix_cli_build
|
||||
- name: Unix build
|
||||
id: unix_build
|
||||
if: matrix.os != 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
cabal build --enable-tests
|
||||
path=$(cabal list-bin simplex-chat)
|
||||
echo "bin_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||
|
||||
- name: Unix upload CLI binary to release
|
||||
- name: Unix upload binary to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.unix_cli_build.outputs.bin_path }}
|
||||
file: ${{ steps.unix_build.outputs.bin_path }}
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Unix update CLI binary hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.unix_cli_build.outputs.bin_hash }}
|
||||
|
||||
- name: Setup Java
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'corretto'
|
||||
java-version: '17'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Linux build desktop
|
||||
id: linux_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/build-lib-linux.sh
|
||||
cd apps/multiplatform
|
||||
./gradlew packageDeb
|
||||
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Linux make AppImage
|
||||
id: linux_appimage_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/make-appimage-linux.sh
|
||||
path=$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage)
|
||||
echo "appimage_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Mac build desktop
|
||||
id: mac_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
|
||||
APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
|
||||
APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
|
||||
run: |
|
||||
scripts/build-desktop-mac.sh
|
||||
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Linux upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.linux_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Linux update desktop package hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.linux_desktop_build.outputs.package_hash }}
|
||||
|
||||
- name: Linux upload AppImage to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.linux_appimage_build.outputs.appimage_path }}
|
||||
asset_name: simplex-desktop-x86_64.AppImage
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Linux update AppImage hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.linux_appimage_build.outputs.appimage_hash }}
|
||||
|
||||
- name: Mac upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.mac_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Mac update desktop package hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.mac_desktop_build.outputs.package_hash }}
|
||||
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest'
|
||||
timeout-minutes: 30
|
||||
@@ -269,12 +147,11 @@ jobs:
|
||||
shell: cmd
|
||||
run: |
|
||||
cabal build --enable-tests
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
path=$(cabal list-bin simplex-chat | tail -n 1)
|
||||
echo "bin_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
cabal list-bin simplex-chat > tmp_bin_path
|
||||
set /p bin_path= < tmp_bin_path
|
||||
echo ::set-output name=bin_path::%bin_path%
|
||||
|
||||
- name: Windows upload CLI binary to release
|
||||
- name: Windows upload binary to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
@@ -283,14 +160,4 @@ jobs:
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Windows update CLI binary hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.windows_build.outputs.bin_hash }}
|
||||
|
||||
# Windows /
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -53,7 +53,6 @@ website/src/docs/
|
||||
website/translations.json
|
||||
website/src/img/images/
|
||||
website/src/images/
|
||||
website/src/js/lottie.min.js
|
||||
# Generated files
|
||||
website/package/generated*
|
||||
|
||||
|
||||
70
PRIVACY.md
70
PRIVACY.md
@@ -6,63 +6,27 @@ If you believe that some of the clauses in this document are not aligned with ou
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best 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 cannot be compromised by the servers via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack).
|
||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||
|
||||
SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol allowing to establish private connections without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
||||
|
||||
SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
SimpleX Chat security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
### Information you provide
|
||||
|
||||
#### User profiles
|
||||
We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
||||
|
||||
We do not store user profiles. The profile you create in the app is local to your device.
|
||||
Messages. SimpleX Chat cannot decrypt or otherwise access the content or even size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are offline, these messages are permanently removed as soon as they are delivered. Your message history is stored only on your own devices.
|
||||
|
||||
When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
||||
|
||||
#### Messages and Files
|
||||
|
||||
SimpleX Chat cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 256kb, 1mb or 8mb via all or some of the configured file servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band.
|
||||
|
||||
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline SimpleX Chat temporarily stores end-to-end encrypted messages on the messaging (SMP) servers that are preset in the app or chosen by the users.
|
||||
|
||||
The messages are permanently removed from the preset servers as soon as they are delivered. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
|
||||
|
||||
The files are stored on file (XFTP) servers for the time configured in the file servers you use (48 hours for preset file servers).
|
||||
|
||||
If a messaging or file servers are restarted, the encrypted message or the record of the file can be stored in a backup file until it is overwritten by the next restart (usually within 1 week).
|
||||
|
||||
#### Connections with other users
|
||||
|
||||
When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on chosen messaging servers, that can be the preset servers or the servers that you configured in the app, in case it allows such configuration. SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default.
|
||||
|
||||
At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
|
||||
|
||||
#### iOS Push Notifications
|
||||
|
||||
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
|
||||
|
||||
Notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers.
|
||||
|
||||
It also does not allow to see message content or sizes, as the actual messages are not sent via the notification server, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot observe it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
|
||||
|
||||
#### Another information stored on the servers
|
||||
Connections with other users. When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on our servers, or on the servers that you configured in the app, in case it allows such configuration (SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default). At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages. The exception to that is when you choose to use instant push notifications in our iOS app, because the design of push notifications requires storing the device token on notification server, and the server can observe how many messaging queues your device uses, and approximate how many messages are sent to each queue. It does not allow though to determine the actual addresses of these queues, as a separate address is used to subscibe to the notifications (unless notification and messaging servers exchange information), and who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers. It also does not allow to see message content or sizes, as the actual messages are not sent via the notification service, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot see it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
|
||||
|
||||
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
|
||||
|
||||
#### SimpleX Directory Service
|
||||
|
||||
[SimpleX directory service](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the group. You can connect to SimpleX Directory Service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
|
||||
|
||||
#### User Support.
|
||||
|
||||
If you contact SimpleX Chat any personal data you may share with us 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.
|
||||
User Support. If you contact SimpleX Chat any personal data you may share with us 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.
|
||||
|
||||
### Information we may share
|
||||
|
||||
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
|
||||
|
||||
We use a third party for email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according to their privacy policies and terms of service.
|
||||
We use Third party to provide email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according their privacy policies and terms of service.
|
||||
|
||||
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
|
||||
|
||||
@@ -75,19 +39,19 @@ At the time of updating this document, we have never provided or have been reque
|
||||
|
||||
### Updates
|
||||
|
||||
We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
||||
We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
||||
|
||||
Please also read our Terms of Service below.
|
||||
Please also read our Terms of Service.
|
||||
|
||||
If you have questions about our Privacy Policy please contact us via [email](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 our Privacy Policy please contact us at chat@simplex.chat.
|
||||
|
||||
## Terms of Service
|
||||
|
||||
You accept our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
||||
You accept to our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
||||
|
||||
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
|
||||
|
||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which 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. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we do cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which 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 cyphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||
|
||||
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
|
||||
|
||||
@@ -103,17 +67,15 @@ You accept our Terms of Service ("Terms") by installing or using any of our apps
|
||||
|
||||
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
|
||||
|
||||
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the application you use. Legacy databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app. In this case, if you make a backup of the app data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the beta version of desktop app currently stores the database passphrase in the configuration file in plaintext, so you may need to remove passphrase from the device via the app configuration.
|
||||
|
||||
**Storing the files on the device**. The files are stored on your device unencrypted. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access the files.
|
||||
**Storing the messages on the device**. Currently the messages are stored in the database on your device without encryption. It means that if you make a backup of the app and store it unecrypted, the backup provider may be able to access the messages.
|
||||
|
||||
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
|
||||
|
||||
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
|
||||
|
||||
**Your Rights**. You own the messages and the information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the app.
|
||||
**Your Rights**. You own the mesasges and information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices.
|
||||
|
||||
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
||||
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 licence](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
||||
|
||||
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
|
||||
|
||||
@@ -131,4 +93,4 @@ You accept our Terms of Service ("Terms") by installing or using any of our apps
|
||||
|
||||
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
|
||||
|
||||
Updated August 17, 2023
|
||||
Updated November 8, 2022
|
||||
|
||||
98
README.md
98
README.md
@@ -2,7 +2,7 @@
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
<a rel="me" href="https://mastodon.social/@simplex"></a>
|
||||
[](https://mastodon.social/@simplex)
|
||||
|
||||
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
## Welcome to SimpleX Chat!
|
||||
|
||||
1. 📲 [Install the app](#install-the-app).
|
||||
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
|
||||
2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups).
|
||||
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
||||
@@ -40,29 +40,21 @@
|
||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
|
||||
|
||||
## Connect to the team
|
||||
|
||||
You can connect to the team via the app using "chat with the developers button" available when you have no conversations in the profile, "Send questions and ideas" in the app settings or via our [SimpleX address](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). Please connect to:
|
||||
## Connect to the team via the app
|
||||
|
||||
- to ask any questions
|
||||
- to suggest any improvements
|
||||
- to share anything relevant
|
||||
|
||||
We are replying the questions manually, so it is not instant – it can take up to 24 hours.
|
||||
|
||||
If you are interested in helping us to integrate open-source language models, and in [joining our team](./docs/JOIN_TEAM.md), please get in touch.
|
||||
|
||||
## Join user groups
|
||||
|
||||
You can join the groups created by other users via the new [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups.
|
||||
|
||||
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
|
||||
|
||||
You also can:
|
||||
- criticize the app, and make comparisons with other messengers.
|
||||
- share new messengers you think could be interesting for privacy, as long as you don't spam.
|
||||
- share some privacy related publications, infrequently.
|
||||
- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it.
|
||||
- having preliminary approved with the admin in direct message, share the link to a group you created.
|
||||
|
||||
You must:
|
||||
- be polite to other users
|
||||
@@ -87,15 +79,6 @@ There are groups in other languages, that we have the apps interface translated
|
||||
|
||||
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||
|
||||
## Follow our updates
|
||||
|
||||
We publish our updates and releases via:
|
||||
|
||||
- [Reddit](https://www.reddit.com/r/SimpleXChat/), [Twitter](https://twitter.com/SimpleXChat), [Lemmy](https://lemmy.ml/c/simplex), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://snort.social/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828).
|
||||
- SimpleX Chat [team profile](#connect-to-the-team).
|
||||
- [blog](https://simplex.chat/blog/) and [RSS feed](https://simplex.chat/feed.rss).
|
||||
- [mailing list](https://simplex.chat/#join-simplex), very rarely.
|
||||
|
||||
## Make a private connection
|
||||
|
||||
You need to share a link with your friend or scan a QR code from their phone, in person or during a video call, to make a connection and start messaging.
|
||||
@@ -119,22 +102,17 @@ Join our translators to help SimpleX grow!
|
||||
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
||||
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
||||
|🇬🇧 en|English | |✓|✓|✓|✓|
|
||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)|[](https://hosted.weblate.org/projects/simplex-chat/android/ar/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||
|🇧🇬 bg|Български | |[](https://hosted.weblate.org/projects/simplex-chat/android/bg/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/bg/)|||
|
||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
||||
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
||||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||
|🇫🇮 fi|Suomi | |[](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|
||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||
|🇮🇱 he|עִברִית | |[](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-|||
|
||||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||
|🇯🇵 ja|日本語 | |[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|
||||
|🇯🇵 ja|Japanese ||[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|||
|
||||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/pl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||
|🇧🇷 pt-BR|Português||[](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|
||||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|
||||
|🇺🇦 uk|Українська| |[](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|
||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|
||||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||
@@ -162,14 +140,11 @@ It is possible to donate via:
|
||||
|
||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- USDT:
|
||||
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
|
||||
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
||||
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||
- Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
||||
|
||||
Thank you,
|
||||
|
||||
@@ -189,7 +164,7 @@ SimpleX Chat founder
|
||||
- [News and updates](#news-and-updates)
|
||||
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
||||
- [SimpleX Platform design](#simplex-platform-design)
|
||||
- [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations)
|
||||
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
||||
- [For developers](#for-developers)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Disclaimers, Security contact, License](#disclaimers)
|
||||
@@ -230,18 +205,22 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
## News and updates
|
||||
|
||||
Recent and important updates:
|
||||
|
||||
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
|
||||
|
||||
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
||||
Recent updates:
|
||||
|
||||
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
|
||||
|
||||
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||
|
||||
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||
|
||||
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
|
||||
[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
||||
|
||||
[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
||||
|
||||
[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
|
||||
|
||||
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||
@@ -274,7 +253,7 @@ See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/stable/p
|
||||
|
||||
See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of messages sent between chat clients over [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md).
|
||||
|
||||
## Privacy and security: technical details and limitations
|
||||
## Privacy: technical details and limitations
|
||||
|
||||
SimpleX Chat is a work in progress – we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
|
||||
|
||||
@@ -287,23 +266,18 @@ What is already implemented:
|
||||
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
|
||||
6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
||||
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
||||
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
||||
12. Manual messaging queue rotations to move conversation to another SMP relay.
|
||||
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
||||
14. Local files encryption, except videos (to be added later).
|
||||
|
||||
We plan to add:
|
||||
We plan to add soon:
|
||||
|
||||
1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
|
||||
2. Post-quantum resistant key exchange in double ratchet protocol.
|
||||
3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
5. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
|
||||
1. Automatic message queue rotation. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
|
||||
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
|
||||
## For developers
|
||||
|
||||
@@ -363,27 +337,23 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
||||
- ✅ Message reactions
|
||||
- ✅ Message editing history
|
||||
- ✅ Reduced battery and traffic usage in large groups.
|
||||
- ✅ Message delivery confirmation (with sender opt-out per contact).
|
||||
- ✅ Desktop client.
|
||||
- ✅ Encryption of local files stored in the app.
|
||||
- 🏗 Using mobile profiles from the desktop app.
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- Post-quantum resistant key exchange in double ratchet protocol.
|
||||
- Large groups, communities and public channels.
|
||||
- Privacy & security slider - a simple way to set all settings at once.
|
||||
- Improve sending videos (including encryption of locally stored videos).
|
||||
- Improve experience for the new users.
|
||||
- 🏗 Desktop client.
|
||||
- 🏗 Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||
- SMP queue redundancy and rotation (manual is supported).
|
||||
- Include optional message into connection request sent via contact address.
|
||||
- Local app files encryption.
|
||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||
- Large groups, communities and public channels.
|
||||
- Feeds/broadcasts.
|
||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Privately share your location.
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
||||
- Supporting the same profile on multiple devices.
|
||||
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||
- keep all your contacts and groups even if you lose the domain.
|
||||
- the server doesn't have information about your contacts and groups.
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- High capacity multi-node SMP relays.
|
||||
|
||||
## Disclaimers
|
||||
|
||||
@@ -14,28 +14,9 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||
application.registerForRemoteNotifications()
|
||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
||||
return true
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private func trackKeyboard() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@objc func keyboardWillShow(_ notification: Notification) {
|
||||
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
||||
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@objc func keyboardWillHide(_ notification: Notification) {
|
||||
ChatModel.shared.keyboardHeight = 0
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
|
||||
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
|
||||
@@ -61,7 +42,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
m.notificationMode != .off {
|
||||
if let verification = ntfData["verification"] as? String,
|
||||
let nonce = ntfData["nonce"] as? String {
|
||||
if let token = m.deviceToken {
|
||||
if let token = ChatModel.shared.deviceToken {
|
||||
logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)")
|
||||
Task {
|
||||
do {
|
||||
@@ -81,7 +62,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
}
|
||||
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
|
||||
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
|
||||
if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic {
|
||||
if appStateGroupDefault.get().inactive {
|
||||
receiveMessages(completionHandler)
|
||||
} else {
|
||||
completionHandler(.noData)
|
||||
@@ -95,7 +76,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
logger.debug("DEBUGGING: AppDelegate: applicationWillTerminate")
|
||||
logger.debug("AppDelegate: applicationWillTerminate")
|
||||
ChatModel.shared.filesToDelete.forEach {
|
||||
removeFile($0)
|
||||
}
|
||||
|
||||
@@ -28,17 +28,6 @@ struct ContentView: View {
|
||||
@State private var showWhatsNew = false
|
||||
@State private var showChooseLAMode = false
|
||||
@State private var showSetPasscode = false
|
||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||
|
||||
private enum ChatListActionSheet: Identifiable {
|
||||
case connectViaUrl(action: ConnReqType, link: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .connectViaUrl(_, link): return "connectViaUrl \(link)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -91,11 +80,6 @@ struct ContentView: View {
|
||||
if case .onboardingComplete = step,
|
||||
chatModel.currentUser != nil {
|
||||
mainView()
|
||||
.actionSheet(item: $chatListActionSheet) { sheet in
|
||||
switch sheet {
|
||||
case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OnboardingView(onboarding: step)
|
||||
}
|
||||
@@ -148,15 +132,10 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
prefShowLANotice = true
|
||||
connectViaUrl()
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
||||
.sheet(isPresented: $showWhatsNew) {
|
||||
WhatsNewView()
|
||||
}
|
||||
if chatModel.setDeliveryReceipts {
|
||||
SetDeliveryReceiptsView()
|
||||
}
|
||||
IncomingCallView()
|
||||
}
|
||||
.onContinueUserActivity("INStartCallIntent", perform: processUserActivity)
|
||||
@@ -197,13 +176,10 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func runAuthenticate() {
|
||||
logger.debug("DEBUGGING: runAuthenticate")
|
||||
if !prefPerformLA {
|
||||
userAuthorized = true
|
||||
} else {
|
||||
logger.debug("DEBUGGING: before dismissAllSheets")
|
||||
dismissAllSheets(animated: false) {
|
||||
logger.debug("DEBUGGING: in dismissAllSheets callback")
|
||||
chatModel.chatId = nil
|
||||
justAuthenticate()
|
||||
}
|
||||
@@ -214,7 +190,7 @@ struct ContentView: View {
|
||||
userAuthorized = false
|
||||
let laMode = privacyLocalAuthModeDefault.get()
|
||||
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
||||
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
|
||||
logger.debug("authenticate callback: \(String(describing: laResult))")
|
||||
switch (laResult) {
|
||||
case .success:
|
||||
userAuthorized = true
|
||||
@@ -283,40 +259,36 @@ struct ContentView: View {
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaUrl() {
|
||||
dismissAllSheets() {
|
||||
let m = ChatModel.shared
|
||||
if let url = m.appOpenUrl {
|
||||
m.appOpenUrl = nil
|
||||
var path = url.path
|
||||
logger.debug("ContentView.connectViaUrl path: \(path)")
|
||||
if (path == "/contact" || path == "/invitation") {
|
||||
path.removeFirst()
|
||||
let action: ConnReqType = path == "contact" ? .contact : .invitation
|
||||
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
|
||||
chatListActionSheet = .connectViaUrl(action: action, link: link)
|
||||
} else {
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
|
||||
}
|
||||
}
|
||||
}
|
||||
func connectViaUrl() {
|
||||
let m = ChatModel.shared
|
||||
if let url = m.appOpenUrl {
|
||||
m.appOpenUrl = nil
|
||||
AlertManager.shared.showAlert(connectViaUrlAlert(url))
|
||||
}
|
||||
}
|
||||
|
||||
private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet {
|
||||
func connectViaUrlAlert(_ url: URL) -> Alert {
|
||||
var path = url.path
|
||||
logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
|
||||
if (path == "/contact" || path == "/invitation") {
|
||||
path.removeFirst()
|
||||
let action: ConnReqType = path == "contact" ? .contact : .invitation
|
||||
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
|
||||
let title: LocalizedStringKey
|
||||
switch action {
|
||||
case .contact: title = "Connect via contact link"
|
||||
case .invitation: title = "Connect via one-time link"
|
||||
}
|
||||
return ActionSheet(
|
||||
if case .contact = action { title = "Connect via contact link?" }
|
||||
else { title = "Connect via one-time link?" }
|
||||
return Alert(
|
||||
title: Text(title),
|
||||
buttons: [
|
||||
.default(Text("Use current profile")) { connectViaLink(link, incognito: false) },
|
||||
.default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) },
|
||||
.cancel()
|
||||
]
|
||||
message: Text("Your profile will be sent to the contact that you received this link from"),
|
||||
primaryButton: .default(Text("Connect")) {
|
||||
connectViaLink(link)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
} else {
|
||||
return Alert(title: Text("Error: URL is invalid"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,15 +103,9 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
self.onFinishPlayback = onFinishPlayback
|
||||
}
|
||||
|
||||
func start(fileSource: CryptoFile, at: TimeInterval?) {
|
||||
let url = getAppFilePath(fileSource.filePath)
|
||||
if let cfArgs = fileSource.cryptoArgs {
|
||||
if let data = try? readCryptoFile(path: url.path, cryptoArgs: cfArgs) {
|
||||
audioPlayer = try? AVAudioPlayer(data: data)
|
||||
}
|
||||
} else {
|
||||
audioPlayer = try? AVAudioPlayer(contentsOf: url)
|
||||
}
|
||||
func start(fileName: String, at: TimeInterval?) {
|
||||
let url = getAppFilePath(fileName)
|
||||
audioPlayer = try? AVAudioPlayer(contentsOf: url)
|
||||
audioPlayer?.delegate = self
|
||||
audioPlayer?.prepareToPlay()
|
||||
if let at = at {
|
||||
|
||||
@@ -34,10 +34,6 @@ class BGManager {
|
||||
}
|
||||
|
||||
func schedule() {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.schedule: disabled")
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.schedule")
|
||||
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval)
|
||||
@@ -49,10 +45,6 @@ class BGManager {
|
||||
}
|
||||
|
||||
private func handleRefresh(_ task: BGAppRefreshTask) {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.handleRefresh: disabled")
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.handleRefresh")
|
||||
schedule()
|
||||
if appStateGroupDefault.get().inactive {
|
||||
|
||||
@@ -11,41 +11,8 @@ import Combine
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
actor TerminalItems {
|
||||
private var terminalItems: [TerminalItem] = []
|
||||
|
||||
static let shared = TerminalItems()
|
||||
|
||||
func items() -> [TerminalItem] {
|
||||
terminalItems
|
||||
}
|
||||
|
||||
func add(_ item: TerminalItem) async {
|
||||
addTermItem(&terminalItems, item)
|
||||
let m = ChatModel.shared
|
||||
if m.showingTerminal {
|
||||
await MainActor.run {
|
||||
addTermItem(&m.terminalItems, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addCommand(_ start: Date, _ cmd: ChatCommand, _ resp: ChatResponse) async {
|
||||
await add(.cmd(start, cmd))
|
||||
await add(.resp(.now, resp))
|
||||
}
|
||||
}
|
||||
|
||||
private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
|
||||
if items.count >= 200 {
|
||||
items.removeFirst()
|
||||
}
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
final class ChatModel: ObservableObject {
|
||||
@Published var onboardingStage: OnboardingStage?
|
||||
@Published var setDeliveryReceipts = false
|
||||
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
||||
@Published var currentUser: User?
|
||||
@Published var users: [UserInfo] = []
|
||||
@@ -65,7 +32,6 @@ final class ChatModel: ObservableObject {
|
||||
@Published var chatToTop: String?
|
||||
@Published var groupMembers: [GroupMember] = []
|
||||
// items in the terminal view
|
||||
@Published var showingTerminal = false
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@Published var userAddress: UserContactLink?
|
||||
@Published var chatItemTTL: ChatItemTTL = .none
|
||||
@@ -75,9 +41,10 @@ final class ChatModel: ObservableObject {
|
||||
@Published var tokenRegistered = false
|
||||
@Published var tokenStatus: NtfTknStatus?
|
||||
@Published var notificationMode = NotificationsMode.off
|
||||
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||
@Published var notificationPreview: NotificationPreviewMode? = ntfPreviewModeGroupDefault.get()
|
||||
@Published var incognito: Bool = incognitoGroupDefault.get()
|
||||
// pending notification actions
|
||||
@Published var ntfContactRequest: NTFContactRequest?
|
||||
@Published var ntfContactRequest: ChatId?
|
||||
@Published var ntfCallInvitationAction: (ChatId, NtfCallAction)?
|
||||
// current WebRTC call
|
||||
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
|
||||
@@ -90,8 +57,6 @@ final class ChatModel: ObservableObject {
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
// tracks keyboard height via subscription in AppDelegate
|
||||
@Published var keyboardHeight: CGFloat = 0
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
|
||||
@@ -101,14 +66,6 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||
|
||||
var ntfEnableLocal: Bool {
|
||||
notificationMode == .off || ntfEnableLocalGroupDefault.get()
|
||||
}
|
||||
|
||||
var ntfEnablePeriodic: Bool {
|
||||
notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get()
|
||||
}
|
||||
|
||||
func getUser(_ userId: Int64) -> User? {
|
||||
currentUser?.userId == userId
|
||||
? currentUser
|
||||
@@ -176,14 +133,6 @@ final class ChatModel: ObservableObject {
|
||||
updateChat(.direct(contact: contact), addMissing: contact.directOrUsed)
|
||||
}
|
||||
|
||||
func updateContactConnectionStats(_ contact: Contact, _ connectionStats: ConnectionStats) {
|
||||
var updatedConn = contact.activeConn
|
||||
updatedConn.connectionStats = connectionStats
|
||||
var updatedContact = contact
|
||||
updatedContact.activeConn = updatedConn
|
||||
updateContact(updatedContact)
|
||||
}
|
||||
|
||||
func updateGroup(_ groupInfo: GroupInfo) {
|
||||
updateChat(.group(groupInfo: groupInfo))
|
||||
}
|
||||
@@ -495,18 +444,18 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func increaseUnreadCounter(user: any UserLike) {
|
||||
func increaseUnreadCounter(user: User) {
|
||||
changeUnreadCounter(user: user, by: 1)
|
||||
NtfManager.shared.incNtfBadgeCount()
|
||||
}
|
||||
|
||||
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
||||
func decreaseUnreadCounter(user: User, by: Int = 1) {
|
||||
changeUnreadCounter(user: user, by: -by)
|
||||
NtfManager.shared.decNtfBadgeCount(by: by)
|
||||
}
|
||||
|
||||
private func changeUnreadCounter(user: any UserLike, by: Int) {
|
||||
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
|
||||
private func changeUnreadCounter(user: User, by: Int) {
|
||||
if let i = users.firstIndex(where: { $0.user.id == user.id }) {
|
||||
users[i].unreadCount += by
|
||||
}
|
||||
}
|
||||
@@ -516,27 +465,14 @@ final class ChatModel: ObservableObject {
|
||||
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
|
||||
}
|
||||
|
||||
func getConnectedMemberNames(_ ci: ChatItem) -> [String] {
|
||||
guard var i = getChatItemIndex(ci) else { return [] }
|
||||
var ns: [String] = []
|
||||
while i < reversedChatItems.count, let m = reversedChatItems[i].memberConnected {
|
||||
ns.append(m.displayName)
|
||||
i += 1
|
||||
}
|
||||
return ns
|
||||
}
|
||||
|
||||
func getChatItemNeighbors(_ ci: ChatItem) -> (ChatItem?, ChatItem?) {
|
||||
if let i = getChatItemIndex(ci) {
|
||||
return (
|
||||
i + 1 < reversedChatItems.count ? reversedChatItems[i + 1] : nil,
|
||||
i - 1 >= 0 ? reversedChatItems[i - 1] : nil
|
||||
)
|
||||
func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
|
||||
if let i = getChatItemIndex(ci), i < reversedChatItems.count - 1 {
|
||||
return reversedChatItems[i + 1]
|
||||
} else {
|
||||
return (nil, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func popChat(_ id: String) {
|
||||
if let i = getChatIndex(id) {
|
||||
popChat_(i)
|
||||
@@ -585,16 +521,6 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) {
|
||||
if let conn = member.activeConn {
|
||||
var updatedConn = conn
|
||||
updatedConn.connectionStats = connectionStats
|
||||
var updatedMember = member
|
||||
updatedMember.activeConn = updatedConn
|
||||
_ = upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
|
||||
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
|
||||
var i = 0
|
||||
var totalBelow = 0
|
||||
@@ -625,11 +551,13 @@ final class ChatModel: ObservableObject {
|
||||
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
|
||||
networkStatuses[contact.activeConn.agentConnId] ?? .unknown
|
||||
}
|
||||
}
|
||||
|
||||
struct NTFContactRequest {
|
||||
var incognito: Bool
|
||||
var chatId: String
|
||||
func addTerminalItem(_ item: TerminalItem) {
|
||||
if terminalItems.count >= 500 {
|
||||
terminalItems.remove(at: 0)
|
||||
}
|
||||
terminalItems.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
struct UnreadChatItemCounts {
|
||||
|
||||
@@ -11,43 +11,42 @@ import SimpleXChat
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
|
||||
if let file = file, file.loaded {
|
||||
return file.fileSource
|
||||
func getLoadedFilePath(_ file: CIFile?) -> String? {
|
||||
if let fileName = getLoadedFileName(file) {
|
||||
return getAppFilePath(fileName).path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLoadedFileName(_ file: CIFile?) -> String? {
|
||||
if let file = file,
|
||||
file.loaded,
|
||||
let fileName = file.filePath {
|
||||
return fileName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
||||
if let fileSource = getLoadedFileSource(file) {
|
||||
let filePath = getAppFilePath(fileSource.filePath)
|
||||
let loadedFilePath = getLoadedFilePath(file)
|
||||
if let loadedFilePath = loadedFilePath, let fileName = file?.filePath {
|
||||
let filePath = getAppFilePath(fileName)
|
||||
do {
|
||||
let data = try getFileData(filePath, fileSource.cryptoArgs)
|
||||
let data = try Data(contentsOf: filePath)
|
||||
let img = UIImage(data: data)
|
||||
do {
|
||||
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
|
||||
return img
|
||||
} catch {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
|
||||
return img
|
||||
} catch {
|
||||
return nil
|
||||
return UIImage(contentsOfFile: loadedFilePath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
|
||||
if let cfArgs = cfArgs {
|
||||
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
|
||||
} else {
|
||||
return try Data(contentsOf: path)
|
||||
}
|
||||
}
|
||||
|
||||
func getLoadedVideo(_ file: CIFile?) -> URL? {
|
||||
if let fileSource = getLoadedFileSource(file) {
|
||||
let filePath = getAppFilePath(fileSource.filePath)
|
||||
let loadedFilePath = getLoadedFilePath(file)
|
||||
if loadedFilePath != nil, let fileName = file?.filePath {
|
||||
let filePath = getAppFilePath(fileName)
|
||||
if FileManager.default.fileExists(atPath: filePath.path) {
|
||||
return filePath
|
||||
}
|
||||
@@ -55,18 +54,18 @@ func getLoadedVideo(_ file: CIFile?) -> URL? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAnimImage(_ image: UIImage) -> CryptoFile? {
|
||||
func saveAnimImage(_ image: UIImage) -> String? {
|
||||
let fileName = generateNewFileName("IMG", "gif")
|
||||
guard let imageData = image.imageData else { return nil }
|
||||
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
||||
return saveFile(imageData, fileName)
|
||||
}
|
||||
|
||||
func saveImage(_ uiImage: UIImage) -> CryptoFile? {
|
||||
func saveImage(_ uiImage: UIImage) -> String? {
|
||||
let hasAlpha = imageHasAlpha(uiImage)
|
||||
let ext = hasAlpha ? "png" : "jpg"
|
||||
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
|
||||
let fileName = generateNewFileName("IMG", ext)
|
||||
return saveFile(imageDataResized, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
||||
return saveFile(imageDataResized, fileName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -158,19 +157,13 @@ func imageHasAlpha(_ img: UIImage) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? {
|
||||
let savedFile: CryptoFile?
|
||||
func saveFileFromURL(_ url: URL) -> String? {
|
||||
let savedFile: String?
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
do {
|
||||
let fileData = try Data(contentsOf: url)
|
||||
let fileName = uniqueCombine(url.lastPathComponent)
|
||||
let toPath = getAppFilePath(fileName).path
|
||||
if encrypted {
|
||||
let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: toPath)
|
||||
savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs)
|
||||
} else {
|
||||
try FileManager.default.copyItem(atPath: url.path, toPath: toPath)
|
||||
savedFile = CryptoFile.plain(fileName)
|
||||
}
|
||||
savedFile = saveFile(fileData, fileName)
|
||||
} catch {
|
||||
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
|
||||
savedFile = nil
|
||||
@@ -183,16 +176,18 @@ func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? {
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
func saveFileFromURLWithoutLoad(_ url: URL) -> String? {
|
||||
let savedFile: String?
|
||||
do {
|
||||
let fileName = uniqueCombine(url.lastPathComponent)
|
||||
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
|
||||
ChatModel.shared.filesToDelete.remove(url)
|
||||
return CryptoFile.plain(fileName)
|
||||
savedFile = fileName
|
||||
} catch {
|
||||
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
|
||||
return nil
|
||||
logger.error("FileUtils.saveFileFromURLWithoutLoad error: \(error.localizedDescription)")
|
||||
savedFile = nil
|
||||
}
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
|
||||
@@ -293,4 +288,4 @@ extension UIImage {
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import UIKit
|
||||
import SimpleXChat
|
||||
|
||||
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
|
||||
let ntfActionAcceptContactIncognito = "NTF_ACT_ACCEPT_CONTACT_INCOGNITO"
|
||||
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
|
||||
let ntfActionRejectCall = "NTF_ACT_REJECT_CALL"
|
||||
|
||||
@@ -42,13 +41,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
userId != chatModel.currentUser?.userId {
|
||||
changeActiveUser(userId, viewPwd: nil)
|
||||
}
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
|
||||
let chatId = content.userInfo["chatId"] as? String {
|
||||
let incognito = action == ntfActionAcceptContactIncognito
|
||||
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
|
||||
Task { await acceptContactRequest(incognito: incognito, contactRequest: contactRequest) }
|
||||
Task { await acceptContactRequest(contactRequest) }
|
||||
} else {
|
||||
chatModel.ntfContactRequest = NTFContactRequest(incognito: incognito, chatId: chatId)
|
||||
chatModel.ntfContactRequest = chatId
|
||||
}
|
||||
} else if let (chatId, ntfAction) = ntfCallAction(content, action) {
|
||||
if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) {
|
||||
@@ -136,17 +134,11 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
UNUserNotificationCenter.current().setNotificationCategories([
|
||||
UNNotificationCategory(
|
||||
identifier: ntfCategoryContactRequest,
|
||||
actions: [
|
||||
UNNotificationAction(
|
||||
identifier: ntfActionAcceptContact,
|
||||
title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
|
||||
options: .foreground
|
||||
), UNNotificationAction(
|
||||
identifier: ntfActionAcceptContactIncognito,
|
||||
title: NSLocalizedString("Accept incognito", comment: "accept contact request via notification"),
|
||||
options: .foreground
|
||||
)
|
||||
],
|
||||
actions: [UNNotificationAction(
|
||||
identifier: ntfActionAcceptContact,
|
||||
title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
|
||||
options: .foreground
|
||||
)],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification")
|
||||
),
|
||||
@@ -211,17 +203,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
center.delegate = self
|
||||
}
|
||||
|
||||
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
|
||||
func notifyContactRequest(_ user: User, _ contactRequest: UserContactRequest) {
|
||||
logger.debug("NtfManager.notifyContactRequest")
|
||||
addNotification(createContactRequestNtf(user, contactRequest))
|
||||
}
|
||||
|
||||
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
|
||||
func notifyContactConnected(_ user: User, _ contact: Contact) {
|
||||
logger.debug("NtfManager.notifyContactConnected")
|
||||
addNotification(createContactConnectedNtf(user, contact))
|
||||
}
|
||||
|
||||
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
func notifyMessageReceived(_ user: User, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
logger.debug("NtfManager.notifyMessageReceived")
|
||||
if cInfo.ntfsEnabled {
|
||||
addNotification(createMessageReceivedNtf(user, cInfo, cItem))
|
||||
|
||||
@@ -15,12 +15,6 @@ import SimpleXChat
|
||||
|
||||
private var chatController: chat_ctrl?
|
||||
|
||||
// currentChatVersion in core
|
||||
public let CURRENT_CHAT_VERSION: Int = 2
|
||||
|
||||
// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core)
|
||||
public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION)
|
||||
|
||||
enum TerminalItem: Identifiable {
|
||||
case cmd(Date, ChatCommand)
|
||||
case resp(Date, ChatResponse)
|
||||
@@ -100,8 +94,9 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
|
||||
if case let .response(_, json) = resp {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
||||
}
|
||||
Task {
|
||||
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.addTerminalItem(.cmd(start, cmd.obfuscated))
|
||||
ChatModel.shared.addTerminalItem(.resp(.now, resp))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -164,24 +159,6 @@ func apiSetActiveUserAsync(_ userId: Int64, viewPwd: String?) async throws -> Us
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetAllContactReceipts(enable: Bool) async throws {
|
||||
let r = await chatSendCmd(.setAllContactReceipts(enable: enable))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetUserContactReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws {
|
||||
let r = await chatSendCmd(.apiSetUserContactReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetUserGroupReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws {
|
||||
let r = await chatSendCmd(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
|
||||
try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd))
|
||||
}
|
||||
@@ -257,6 +234,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetIncognito(incognito: Bool) throws {
|
||||
let r = chatSendCmdSync(.setIncognito(incognito: incognito))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiExportArchive(config: ArchiveConfig) async throws {
|
||||
try await sendCommandOkResp(.apiExportArchive(config: config))
|
||||
}
|
||||
@@ -321,23 +304,17 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
|
||||
let chatModel = ChatModel.shared
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
|
||||
let r: ChatResponse
|
||||
if type == .direct {
|
||||
var cItem: ChatItem? = nil
|
||||
let endTask = beginBGTask({
|
||||
if let cItem = cItem {
|
||||
DispatchQueue.main.async {
|
||||
chatModel.messageDelivery.removeValue(forKey: cItem.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
var cItem: ChatItem!
|
||||
let endTask = beginBGTask({ if cItem != nil { chatModel.messageDelivery.removeValue(forKey: cItem.id) } })
|
||||
r = await chatSendCmd(cmd, bgTask: false)
|
||||
if case let .newChatItem(_, aChatItem) = r {
|
||||
cItem = aChatItem.chatItem
|
||||
chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
|
||||
chatModel.messageDelivery[cItem.id] = endTask
|
||||
return cItem
|
||||
}
|
||||
if let networkErrorAlert = networkErrorAlert(r) {
|
||||
@@ -487,10 +464,6 @@ func setNetworkConfig(_ cfg: NetCfg) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func reconnectAllServers() async throws {
|
||||
try await sendCommandOkResp(.reconnectAllServers)
|
||||
}
|
||||
|
||||
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
|
||||
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
||||
}
|
||||
@@ -501,9 +474,9 @@ func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profi
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
|
||||
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) {
|
||||
let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
|
||||
if case let .groupMemberInfo(_, _, _, connStats_) = r { return (connStats_) }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -531,18 +504,6 @@ func apiAbortSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSyncContactRatchet(_ contactId: Int64, _ force: Bool) throws -> ConnectionStats {
|
||||
let r = chatSendCmdSync(.apiSyncContactRatchet(contactId: contactId, force: force))
|
||||
if case let .contactRatchetSyncStarted(_, _, connectionStats) = r { return connectionStats }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSyncGroupMemberRatchet(_ groupId: Int64, _ groupMemberId: Int64, _ force: Bool) throws -> (GroupMember, ConnectionStats) {
|
||||
let r = chatSendCmdSync(.apiSyncGroupMemberRatchet(groupId: groupId, groupMemberId: groupMemberId, force: force))
|
||||
if case let .groupMemberRatchetSyncStarted(_, _, member, connectionStats) = r { return (member, connectionStats) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
|
||||
let r = await chatSendCmd(.apiGetContactCode(contactId: contactId))
|
||||
if case let .contactCode(_, contact, connectionCode) = r { return (contact, connectionCode) }
|
||||
@@ -569,25 +530,19 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiAddContact(incognito: Bool) async -> (String, PendingContactConnection)? {
|
||||
func apiAddContact() async -> String? {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiAddContact: no current user")
|
||||
return nil
|
||||
}
|
||||
let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false)
|
||||
if case let .invitation(_, connReqInvitation, connection) = r { return (connReqInvitation, connection) }
|
||||
let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false)
|
||||
if case let .invitation(_, connReqInvitation) = r { return connReqInvitation }
|
||||
AlertManager.shared.showAlert(connectionErrorAlert(r))
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? {
|
||||
let r = await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito))
|
||||
if case let .connectionIncognitoUpdated(_, toConnection) = r { return toConnection }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
|
||||
let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
|
||||
func apiConnect(connReq: String) async -> ConnReqType? {
|
||||
let (connReqType, alert) = await apiConnect_(connReq: connReq)
|
||||
if let alert = alert {
|
||||
AlertManager.shared.showAlert(alert)
|
||||
return nil
|
||||
@@ -596,12 +551,12 @@ func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
|
||||
}
|
||||
}
|
||||
|
||||
func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) {
|
||||
func apiConnect_(connReq: String) async -> (ConnReqType?, Alert?) {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiConnect: no current user")
|
||||
return (nil, nil)
|
||||
}
|
||||
let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq))
|
||||
let r = await chatSendCmd(.apiConnect(userId: userId, connReq: connReq))
|
||||
switch r {
|
||||
case .sentConfirmation: return (.invitation, nil)
|
||||
case .sentInvitation: return (.contact, nil)
|
||||
@@ -697,12 +652,12 @@ func apiListContacts() throws -> [Contact] {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? {
|
||||
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
||||
let userId = try currentUserId("apiUpdateProfile")
|
||||
let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile))
|
||||
switch r {
|
||||
case .userProfileNoChange: return nil
|
||||
case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts)
|
||||
case let .userProfileUpdated(_, _, toProfile): return toProfile
|
||||
default: throw r
|
||||
}
|
||||
}
|
||||
@@ -712,7 +667,7 @@ func apiSetProfileAddress(on: Bool) async throws -> User? {
|
||||
let r = await chatSendCmd(.apiSetProfileAddress(userId: userId, on: on))
|
||||
switch r {
|
||||
case .userProfileNoChange: return nil
|
||||
case let .userProfileUpdated(user, _, _, _): return user
|
||||
case let .userProfileUpdated(user, _, _): return user
|
||||
default: throw r
|
||||
}
|
||||
}
|
||||
@@ -777,8 +732,8 @@ func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContac
|
||||
}
|
||||
}
|
||||
|
||||
func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? {
|
||||
let r = await chatSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId))
|
||||
func apiAcceptContactRequest(contactReqId: Int64) async -> Contact? {
|
||||
let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
|
||||
let am = AlertManager.shared
|
||||
|
||||
if case let .acceptingContactRequest(_, contact) = r { return contact }
|
||||
@@ -813,35 +768,29 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
|
||||
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
|
||||
}
|
||||
|
||||
func receiveFile(user: any UserLike, fileId: Int64, encrypted: Bool, auto: Bool = false) async {
|
||||
if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: encrypted, auto: auto) {
|
||||
await chatItemSimpleUpdate(user, chatItem)
|
||||
func receiveFile(user: User, fileId: Int64) async {
|
||||
if let chatItem = await apiReceiveFile(fileId: fileId) {
|
||||
DispatchQueue.main.async { chatItemSimpleUpdate(user, chatItem) }
|
||||
}
|
||||
}
|
||||
|
||||
func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
|
||||
let r = await chatSendCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline))
|
||||
func apiReceiveFile(fileId: Int64, inline: Bool? = nil) async -> AChatItem? {
|
||||
let r = await chatSendCmd(.receiveFile(fileId: fileId, inline: inline))
|
||||
let am = AlertManager.shared
|
||||
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
|
||||
if case .rcvFileAcceptedSndCancelled = r {
|
||||
logger.debug("apiReceiveFile error: sender cancelled file transfer")
|
||||
if !auto {
|
||||
am.showAlertMsg(
|
||||
title: "Cannot receive file",
|
||||
message: "Sender cancelled file transfer."
|
||||
)
|
||||
}
|
||||
am.showAlertMsg(
|
||||
title: "Cannot receive file",
|
||||
message: "Sender cancelled file transfer."
|
||||
)
|
||||
} else if let networkErrorAlert = networkErrorAlert(r) {
|
||||
logger.error("apiReceiveFile network error: \(String(describing: r))")
|
||||
am.showAlert(networkErrorAlert)
|
||||
} else {
|
||||
switch chatError(r) {
|
||||
case .fileCancelled:
|
||||
logger.debug("apiReceiveFile ignoring fileCancelled error")
|
||||
case .fileAlreadyReceiving:
|
||||
logger.error("apiReceiveFile error: \(String(describing: r))")
|
||||
switch r {
|
||||
case .chatCmdError(_, .error(.fileAlreadyReceiving)):
|
||||
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
|
||||
default:
|
||||
logger.error("apiReceiveFile error: \(String(describing: r))")
|
||||
am.showAlertMsg(
|
||||
title: "Error receiving file",
|
||||
message: "Error: \(String(describing: r))"
|
||||
@@ -853,7 +802,7 @@ func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: B
|
||||
|
||||
func cancelFile(user: User, fileId: Int64) async {
|
||||
if let chatItem = await apiCancelFile(fileId: fileId) {
|
||||
await chatItemSimpleUpdate(user, chatItem)
|
||||
DispatchQueue.main.async { chatItemSimpleUpdate(user, chatItem) }
|
||||
cleanupFile(chatItem)
|
||||
}
|
||||
}
|
||||
@@ -886,8 +835,8 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||
}
|
||||
}
|
||||
|
||||
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
|
||||
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
|
||||
func acceptContactRequest(_ contactRequest: UserContactRequest) async {
|
||||
if let contact = await apiAcceptContactRequest(contactReqId: contactRequest.apiId) {
|
||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
|
||||
}
|
||||
@@ -1096,18 +1045,6 @@ func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? {
|
||||
}
|
||||
}
|
||||
|
||||
func apiCreateMemberContact(_ groupId: Int64, _ groupMemberId: Int64) async throws -> Contact {
|
||||
let r = await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .newMemberContact(_, contact, _, _) = r { return contact }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSendMemberContactInvitation(_ contactId: Int64, _ msg: MsgContent) async throws -> Contact {
|
||||
let r = await chatSendCmd(.apiSendMemberContactInvitation(contactId: contactId, msg: msg), bgDelay: msgDelay)
|
||||
if case let .newMemberContactSentInv(_, contact, _, _) = r { return contact }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetVersion() throws -> CoreVersionInfo {
|
||||
let r = chatSendCmdSync(.showVersion)
|
||||
if case let .versionInfo(info, _, _) = r { return info }
|
||||
@@ -1133,11 +1070,11 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
|
||||
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
|
||||
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
||||
try setXFTPConfig(getXFTPCfg())
|
||||
try apiSetIncognito(incognito: incognitoGroupDefault.get())
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
if m.currentUser == nil {
|
||||
onboardingStageDefault.set(.step1_SimpleXInfo)
|
||||
privacyDeliveryReceiptsSet.set(true)
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
} else if start {
|
||||
try startChat(refreshInvitations: refreshInvitations)
|
||||
@@ -1167,9 +1104,6 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
|
||||
? .step3_CreateSimpleXAddress
|
||||
: savedOnboardingStage
|
||||
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
|
||||
m.setDeliveryReceipts = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatReceiver.shared.start()
|
||||
@@ -1267,50 +1201,38 @@ class ChatReceiver {
|
||||
}
|
||||
|
||||
func processReceivedMsg(_ res: ChatResponse) async {
|
||||
Task {
|
||||
await TerminalItems.shared.add(.resp(.now, res))
|
||||
}
|
||||
let m = ChatModel.shared
|
||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||
switch res {
|
||||
case let .newContactConnection(user, connection):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
await MainActor.run {
|
||||
m.addTerminalItem(.resp(.now, res))
|
||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||
switch res {
|
||||
case let .newContactConnection(user, connection):
|
||||
if active(user) {
|
||||
m.updateContactConnection(connection)
|
||||
}
|
||||
}
|
||||
case let .contactConnectionDeleted(user, connection):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .contactConnectionDeleted(user, connection):
|
||||
if active(user) {
|
||||
m.removeChat(connection.id)
|
||||
}
|
||||
}
|
||||
case let .contactConnected(user, contact, _):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
case let .contactConnected(user, contact, _):
|
||||
if active(user) && contact.directOrUsed {
|
||||
m.updateContact(contact)
|
||||
m.dismissConnReqView(contact.activeConn.id)
|
||||
m.removeChat(contact.activeConn.id)
|
||||
}
|
||||
}
|
||||
if contact.directOrUsed {
|
||||
NtfManager.shared.notifyContactConnected(user, contact)
|
||||
}
|
||||
await MainActor.run {
|
||||
if contact.directOrUsed {
|
||||
NtfManager.shared.notifyContactConnected(user, contact)
|
||||
}
|
||||
m.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
case let .contactConnecting(user, contact):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
case let .contactConnecting(user, contact):
|
||||
if active(user) && contact.directOrUsed {
|
||||
m.updateContact(contact)
|
||||
m.dismissConnReqView(contact.activeConn.id)
|
||||
m.removeChat(contact.activeConn.id)
|
||||
}
|
||||
}
|
||||
case let .receivedContactRequest(user, contactRequest):
|
||||
if active(user) {
|
||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||
await MainActor.run {
|
||||
case let .receivedContactRequest(user, contactRequest):
|
||||
if active(user) {
|
||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||
if m.hasChat(contactRequest.id) {
|
||||
m.updateChatInfo(cInfo)
|
||||
} else {
|
||||
@@ -1320,318 +1242,244 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
NtfManager.shared.notifyContactRequest(user, contactRequest)
|
||||
case let .contactUpdated(user, toContact):
|
||||
if active(user) && m.hasChat(toContact.id) {
|
||||
await MainActor.run {
|
||||
NtfManager.shared.notifyContactRequest(user, contactRequest)
|
||||
case let .contactUpdated(user, toContact):
|
||||
if active(user) && m.hasChat(toContact.id) {
|
||||
let cInfo = ChatInfo.direct(contact: toContact)
|
||||
m.updateChatInfo(cInfo)
|
||||
}
|
||||
}
|
||||
case let .contactsMerged(user, intoContact, mergedContact):
|
||||
if active(user) && m.hasChat(mergedContact.id) {
|
||||
await MainActor.run {
|
||||
case let .contactsMerged(user, intoContact, mergedContact):
|
||||
if active(user) && m.hasChat(mergedContact.id) {
|
||||
if m.chatId == mergedContact.id {
|
||||
m.chatId = intoContact.id
|
||||
}
|
||||
m.removeChat(mergedContact.id)
|
||||
}
|
||||
}
|
||||
case let .contactsSubscribed(_, contactRefs):
|
||||
await updateContactsStatus(contactRefs, status: .connected)
|
||||
case let .contactsDisconnected(_, contactRefs):
|
||||
await updateContactsStatus(contactRefs, status: .disconnected)
|
||||
case let .contactSubError(user, contact, chatError):
|
||||
await MainActor.run {
|
||||
case let .contactsSubscribed(_, contactRefs):
|
||||
updateContactsStatus(contactRefs, status: .connected)
|
||||
case let .contactsDisconnected(_, contactRefs):
|
||||
updateContactsStatus(contactRefs, status: .disconnected)
|
||||
case let .contactSubError(user, contact, chatError):
|
||||
if active(user) {
|
||||
m.updateContact(contact)
|
||||
}
|
||||
processContactSubError(contact, chatError)
|
||||
}
|
||||
case let .contactSubSummary(_, contactSubscriptions):
|
||||
await MainActor.run {
|
||||
case let .contactSubSummary(user, contactSubscriptions):
|
||||
for sub in contactSubscriptions {
|
||||
// no need to update contact here, and it is slow
|
||||
// if active(user) {
|
||||
// m.updateContact(sub.contact)
|
||||
// }
|
||||
if active(user) {
|
||||
m.updateContact(sub.contact)
|
||||
}
|
||||
if let err = sub.contactError {
|
||||
processContactSubError(sub.contact, err)
|
||||
} else {
|
||||
m.setContactNetworkStatus(sub.contact, .connected)
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .newChatItem(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
await MainActor.run {
|
||||
case let .newChatItem(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if active(user) {
|
||||
m.addChatItem(cInfo, cItem)
|
||||
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
|
||||
m.increaseUnreadCounter(user: user)
|
||||
}
|
||||
}
|
||||
if let file = cItem.autoReceiveFile() {
|
||||
Task {
|
||||
await receiveFile(user: user, fileId: file.fileId, encrypted: cItem.encryptLocalFile, auto: true)
|
||||
}
|
||||
}
|
||||
if cItem.showNotification {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
}
|
||||
case let .chatItemStatusUpdated(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if !cItem.isDeletedContent {
|
||||
let added = active(user) ? await MainActor.run { m.upsertChatItem(cInfo, cItem) } : true
|
||||
if added && cItem.showNotification {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
}
|
||||
}
|
||||
if let endTask = m.messageDelivery[cItem.id] {
|
||||
switch cItem.meta.itemStatus {
|
||||
case .sndSent: endTask()
|
||||
case .sndErrorAuth: endTask()
|
||||
case .sndError: endTask()
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
case let .chatItemUpdated(user, aChatItem):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .chatItemReaction(user, _, r):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
|
||||
}
|
||||
}
|
||||
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
|
||||
if !active(user) {
|
||||
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
|
||||
await MainActor.run {
|
||||
m.decreaseUnreadCounter(user: user)
|
||||
if let file = cItem.autoReceiveFile() {
|
||||
Task {
|
||||
await receiveFile(user: user, fileId: file.fileId)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if cItem.showNotification {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
}
|
||||
case let .chatItemStatusUpdated(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if !cItem.isDeletedContent && (!active(user) || m.upsertChatItem(cInfo, cItem)) {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
}
|
||||
if let endTask = m.messageDelivery[cItem.id] {
|
||||
switch cItem.meta.itemStatus {
|
||||
case .sndSent: endTask()
|
||||
case .sndErrorAuth: endTask()
|
||||
case .sndError: endTask()
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
case let .chatItemUpdated(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .chatItemReaction(user, _, r):
|
||||
if active(user) {
|
||||
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
|
||||
}
|
||||
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
|
||||
if !active(user) {
|
||||
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
|
||||
m.decreaseUnreadCounter(user: user)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
if let toChatItem = toChatItem {
|
||||
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
||||
} else {
|
||||
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
|
||||
}
|
||||
}
|
||||
case let .receivedGroupInvitation(user, groupInfo, _, _):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .receivedGroupInvitation(user, groupInfo, _, _):
|
||||
if active(user) {
|
||||
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
|
||||
// NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation?
|
||||
}
|
||||
}
|
||||
case let .userAcceptedGroupSent(user, groupInfo, hostContact):
|
||||
if !active(user) { return }
|
||||
case let .userAcceptedGroupSent(user, groupInfo, hostContact):
|
||||
if !active(user) { return }
|
||||
|
||||
await MainActor.run {
|
||||
m.updateGroup(groupInfo)
|
||||
if let hostContact = hostContact {
|
||||
m.dismissConnReqView(hostContact.activeConn.id)
|
||||
m.removeChat(hostContact.activeConn.id)
|
||||
}
|
||||
}
|
||||
case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
|
||||
if active(user) {
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
}
|
||||
case let .deletedMemberUser(user, groupInfo, _): // TODO update user member
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .deletedMemberUser(user, groupInfo, _): // TODO update user member
|
||||
if active(user) {
|
||||
m.updateGroup(groupInfo)
|
||||
}
|
||||
}
|
||||
case let .deletedMember(user, groupInfo, _, deletedMember):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .deletedMember(user, groupInfo, _, deletedMember):
|
||||
if active(user) {
|
||||
_ = m.upsertGroupMember(groupInfo, deletedMember)
|
||||
}
|
||||
}
|
||||
case let .leftMember(user, groupInfo, member):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .leftMember(user, groupInfo, member):
|
||||
if active(user) {
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
}
|
||||
case let .groupDeleted(user, groupInfo, _): // TODO update user member
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .groupDeleted(user, groupInfo, _): // TODO update user member
|
||||
if active(user) {
|
||||
m.updateGroup(groupInfo)
|
||||
}
|
||||
}
|
||||
case let .userJoinedGroup(user, groupInfo):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .userJoinedGroup(user, groupInfo):
|
||||
if active(user) {
|
||||
m.updateGroup(groupInfo)
|
||||
}
|
||||
}
|
||||
case let .joinedGroupMember(user, groupInfo, member):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .joinedGroupMember(user, groupInfo, member):
|
||||
if active(user) {
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
}
|
||||
case let .connectedToGroupMember(user, groupInfo, member, memberContact):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .connectedToGroupMember(user, groupInfo, member, memberContact):
|
||||
if active(user) {
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
}
|
||||
if let contact = memberContact {
|
||||
await MainActor.run {
|
||||
if let contact = memberContact {
|
||||
m.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
}
|
||||
case let .groupUpdated(user, toGroup):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .groupUpdated(user, toGroup):
|
||||
if active(user) {
|
||||
m.updateGroup(toGroup)
|
||||
}
|
||||
}
|
||||
case let .memberRole(user, groupInfo, _, _, _, _):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
case let .memberRole(user, groupInfo, _, _, _, _):
|
||||
if active(user) {
|
||||
m.updateGroup(groupInfo)
|
||||
}
|
||||
case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileStart(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileComplete(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileSndCancelled(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupFile(aChatItem)
|
||||
case let .rcvFileProgressXFTP(user, aChatItem, _, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileError(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupFile(aChatItem)
|
||||
case let .sndFileStart(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileComplete(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupDirectFile(aChatItem)
|
||||
case let .sndFileRcvCancelled(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupDirectFile(aChatItem)
|
||||
case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileCompleteXFTP(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupFile(aChatItem)
|
||||
case let .sndFileError(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupFile(aChatItem)
|
||||
case let .callInvitation(invitation):
|
||||
m.callInvitations[invitation.contact.id] = invitation
|
||||
activateCall(invitation)
|
||||
case let .callOffer(_, contact, callType, offer, sharedKey, _):
|
||||
withCall(contact) { call in
|
||||
call.callState = .offerReceived
|
||||
call.peerMedia = callType.media
|
||||
call.sharedKey = sharedKey
|
||||
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
let iceServers = getIceServers()
|
||||
logger.debug(".callOffer useRelay \(useRelay)")
|
||||
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
|
||||
m.callCommand = .offer(
|
||||
offer: offer.rtcSession,
|
||||
iceCandidates: offer.rtcIceCandidates,
|
||||
media: callType.media, aesKey: sharedKey,
|
||||
iceServers: iceServers,
|
||||
relay: useRelay
|
||||
)
|
||||
}
|
||||
case let .callAnswer(_, contact, answer):
|
||||
withCall(contact) { call in
|
||||
call.callState = .answerReceived
|
||||
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
|
||||
}
|
||||
case let .callExtraInfo(_, contact, extraInfo):
|
||||
withCall(contact) { _ in
|
||||
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
|
||||
}
|
||||
case let .callEnded(_, contact):
|
||||
if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
|
||||
CallController.shared.reportCallRemoteEnded(invitation: invitation)
|
||||
}
|
||||
withCall(contact) { call in
|
||||
m.callCommand = .end
|
||||
CallController.shared.reportCallRemoteEnded(call: call)
|
||||
}
|
||||
case .chatSuspended:
|
||||
chatSuspended()
|
||||
default:
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
case let .newMemberContactReceivedInv(user, contact, _, _):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
m.updateContact(contact)
|
||||
|
||||
func withCall(_ contact: Contact, _ perform: (Call) -> Void) {
|
||||
if let call = m.activeCall, call.contact.apiId == contact.apiId {
|
||||
perform(call)
|
||||
} else {
|
||||
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
|
||||
}
|
||||
}
|
||||
case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileStart(user, aChatItem):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileComplete(user, aChatItem):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileSndCancelled(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
case let .rcvFileProgressXFTP(user, aChatItem, _, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileError(user, aChatItem):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
case let .sndFileStart(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileComplete(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupDirectFile(aChatItem) }
|
||||
case let .sndFileRcvCancelled(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupDirectFile(aChatItem) }
|
||||
case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileCompleteXFTP(user, aChatItem, _):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
case let .sndFileError(user, aChatItem):
|
||||
await chatItemSimpleUpdate(user, aChatItem)
|
||||
Task { cleanupFile(aChatItem) }
|
||||
case let .callInvitation(invitation):
|
||||
await MainActor.run {
|
||||
m.callInvitations[invitation.contact.id] = invitation
|
||||
}
|
||||
activateCall(invitation)
|
||||
case let .callOffer(_, contact, callType, offer, sharedKey, _):
|
||||
await withCall(contact) { call in
|
||||
call.callState = .offerReceived
|
||||
call.peerMedia = callType.media
|
||||
call.sharedKey = sharedKey
|
||||
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
let iceServers = getIceServers()
|
||||
logger.debug(".callOffer useRelay \(useRelay)")
|
||||
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
|
||||
m.callCommand = .offer(
|
||||
offer: offer.rtcSession,
|
||||
iceCandidates: offer.rtcIceCandidates,
|
||||
media: callType.media, aesKey: sharedKey,
|
||||
iceServers: iceServers,
|
||||
relay: useRelay
|
||||
)
|
||||
}
|
||||
case let .callAnswer(_, contact, answer):
|
||||
await withCall(contact) { call in
|
||||
call.callState = .answerReceived
|
||||
m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
|
||||
}
|
||||
case let .callExtraInfo(_, contact, extraInfo):
|
||||
await withCall(contact) { _ in
|
||||
m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
|
||||
}
|
||||
case let .callEnded(_, contact):
|
||||
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
|
||||
CallController.shared.reportCallRemoteEnded(invitation: invitation)
|
||||
}
|
||||
await withCall(contact) { call in
|
||||
m.callCommand = .end
|
||||
CallController.shared.reportCallRemoteEnded(call: call)
|
||||
}
|
||||
case .chatSuspended:
|
||||
chatSuspended()
|
||||
case let .contactSwitch(_, contact, switchProgress):
|
||||
await MainActor.run {
|
||||
m.updateContactConnectionStats(contact, switchProgress.connectionStats)
|
||||
}
|
||||
case let .groupMemberSwitch(_, groupInfo, member, switchProgress):
|
||||
await MainActor.run {
|
||||
m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
|
||||
}
|
||||
case let .contactRatchetSync(_, contact, ratchetSyncProgress):
|
||||
await MainActor.run {
|
||||
m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
|
||||
}
|
||||
case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress):
|
||||
await MainActor.run {
|
||||
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
|
||||
}
|
||||
default:
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
|
||||
func withCall(_ contact: Contact, _ perform: (Call) -> Void) async {
|
||||
if let call = m.activeCall, call.contact.apiId == contact.apiId {
|
||||
await MainActor.run { perform(call) }
|
||||
} else {
|
||||
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func active(_ user: any UserLike) -> Bool {
|
||||
user.userId == ChatModel.shared.currentUser?.id
|
||||
func active(_ user: User) -> Bool {
|
||||
user.id == ChatModel.shared.currentUser?.id
|
||||
}
|
||||
|
||||
func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
|
||||
func chatItemSimpleUpdate(_ user: User, _ aChatItem: AChatItem) {
|
||||
let m = ChatModel.shared
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if active(user) {
|
||||
if await MainActor.run(body: { m.upsertChatItem(cInfo, cItem) }) {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
}
|
||||
if active(user) && m.upsertChatItem(cInfo, cItem) {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
}
|
||||
}
|
||||
|
||||
func updateContactsStatus(_ contactRefs: [ContactRef], status: NetworkStatus) async {
|
||||
func updateContactsStatus(_ contactRefs: [ContactRef], status: NetworkStatus) {
|
||||
let m = ChatModel.shared
|
||||
await MainActor.run {
|
||||
for c in contactRefs {
|
||||
m.networkStatuses[c.agentConnId] = status
|
||||
}
|
||||
for c in contactRefs {
|
||||
m.networkStatuses[c.agentConnId] = status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1670,9 +1518,7 @@ func activateCall(_ callInvitation: RcvCallInvitation) {
|
||||
let m = ChatModel.shared
|
||||
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
|
||||
}
|
||||
m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
|
||||
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
|
||||
} else {
|
||||
logger.debug("reportNewIncomingCall success")
|
||||
@@ -1684,3 +1530,15 @@ private struct UserResponse: Decodable {
|
||||
var user: User?
|
||||
var error: String?
|
||||
}
|
||||
|
||||
struct RuntimeError: Error {
|
||||
let message: String
|
||||
|
||||
init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
public var localizedDescription: String {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +76,9 @@ private func _chatSuspended() {
|
||||
}
|
||||
|
||||
func activateChat(appState: AppState = .active) {
|
||||
logger.debug("DEBUGGING: activateChat")
|
||||
suspendLockQueue.sync {
|
||||
appStateGroupDefault.set(appState)
|
||||
if ChatModel.ok { apiActivateChat() }
|
||||
logger.debug("DEBUGGING: activateChat: after apiActivateChat")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,14 +95,10 @@ func initChatAndMigrate(refreshInvitations: Bool = true) {
|
||||
}
|
||||
|
||||
func startChatAndActivate() {
|
||||
logger.debug("DEBUGGING: startChatAndActivate")
|
||||
if ChatModel.shared.chatRunning == true {
|
||||
ChatReceiver.shared.start()
|
||||
logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start")
|
||||
}
|
||||
if .active != appStateGroupDefault.get() {
|
||||
logger.debug("DEBUGGING: startChatAndActivate: before activateChat")
|
||||
activateChat()
|
||||
logger.debug("DEBUGGING: startChatAndActivate: after activateChat")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,10 +139,10 @@ struct SimpleXApp: App {
|
||||
let chat = chatModel.getChat(id) {
|
||||
loadChat(chat: chat)
|
||||
}
|
||||
if let ncr = chatModel.ntfContactRequest {
|
||||
if let chatId = chatModel.ntfContactRequest {
|
||||
chatModel.ntfContactRequest = nil
|
||||
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
|
||||
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
|
||||
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
|
||||
Task { await acceptContactRequest(contactRequest) }
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
|
||||
@@ -57,37 +57,6 @@ private func serverHost(_ s: String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
enum SendReceipts: Identifiable, Hashable {
|
||||
case yes
|
||||
case no
|
||||
case userDefault(Bool)
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var text: LocalizedStringKey {
|
||||
switch self {
|
||||
case .yes: return "yes"
|
||||
case .no: return "no"
|
||||
case let .userDefault(on): return on ? "default (yes)" : "default (no)"
|
||||
}
|
||||
}
|
||||
|
||||
func bool() -> Bool? {
|
||||
switch self {
|
||||
case .yes: return true
|
||||
case .no: return false
|
||||
case .userDefault: return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func fromBool(_ enable: Bool?, userDefault def: Bool) -> SendReceipts {
|
||||
if let enable = enable {
|
||||
return enable ? .yes : .no
|
||||
}
|
||||
return .userDefault(def)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@@ -99,8 +68,6 @@ struct ChatInfoView: View {
|
||||
@Binding var connectionCode: String?
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: ChatInfoViewAlert? = nil
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum ChatInfoViewAlert: Identifiable {
|
||||
@@ -109,7 +76,6 @@ struct ChatInfoView: View {
|
||||
case networkStatusAlert
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case syncConnectionForceAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
@@ -119,7 +85,6 @@ struct ChatInfoView: View {
|
||||
case .networkStatusAlert: return "networkStatusAlert"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
@@ -143,28 +108,14 @@ struct ChatInfoView: View {
|
||||
|
||||
if let customUserProfile = customUserProfile {
|
||||
Section("Incognito") {
|
||||
HStack {
|
||||
Text("Your random profile")
|
||||
Spacer()
|
||||
Text(customUserProfile.chatViewName)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
infoRow("Your random profile", customUserProfile.chatViewName)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
sendReceiptsOption()
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready)
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
@@ -181,33 +132,24 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready {
|
||||
Section("Servers") {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
!contact.ready
|
||||
|| connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
Section("Servers") {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil })
|
||||
if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch })
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,12 +168,6 @@ struct ChatInfoView: View {
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.onAppear {
|
||||
if let currentUser = chatModel.currentUser {
|
||||
sendReceiptsUserDefault = currentUser.sendRcptsContacts
|
||||
}
|
||||
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||
}
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case .deleteContactAlert: return deleteContactAlert()
|
||||
@@ -239,7 +175,6 @@ struct ChatInfoView: View {
|
||||
case .networkStatusAlert: return networkStatusAlert()
|
||||
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
@@ -252,30 +187,20 @@ struct ChatInfoView: View {
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
if contact.verified {
|
||||
(
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
HStack {
|
||||
if contact.verified {
|
||||
Image(systemName: "checkmark.shield")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.title2)
|
||||
+ Text(" ")
|
||||
+ Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.bottom, 2)
|
||||
} else {
|
||||
}
|
||||
Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
|
||||
Text(cInfo.fullName)
|
||||
.font(.title2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -355,44 +280,6 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendReceiptsOption() -> some View {
|
||||
Picker(selection: $sendReceipts) {
|
||||
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
|
||||
Text(opt.text)
|
||||
}
|
||||
} label: {
|
||||
Label("Send receipts", systemImage: "checkmark.message")
|
||||
}
|
||||
.frame(height: 36)
|
||||
.onChange(of: sendReceipts) { _ in
|
||||
setSendReceipts()
|
||||
}
|
||||
}
|
||||
|
||||
private func setSendReceipts() {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.sendRcpts = sendReceipts.bool()
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
syncContactConnection(force: false)
|
||||
} label: {
|
||||
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButtonForce() -> some View {
|
||||
Button {
|
||||
alert = .syncConnectionForceAlert
|
||||
} label: {
|
||||
Label("Renegotiate encryption", systemImage: "exclamationmark.triangle")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func networkStatusRow() -> some View {
|
||||
HStack {
|
||||
Text("Network status")
|
||||
@@ -440,9 +327,9 @@ struct ChatInfoView: View {
|
||||
do {
|
||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
chatModel.chatId = nil
|
||||
chatModel.removeChat(chat.chatInfo.id)
|
||||
chatModel.chatId = nil
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
|
||||
@@ -483,10 +370,6 @@ struct ChatInfoView: View {
|
||||
do {
|
||||
let stats = try apiSwitchContact(contactId: contact.apiId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateContactConnectionStats(contact, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("switchContactAddress apiSwitchContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -502,9 +385,6 @@ struct ChatInfoView: View {
|
||||
do {
|
||||
let stats = try apiAbortSwitchContact(contact.apiId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateContactConnectionStats(contact, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("abortSwitchContactAddress apiAbortSwitchContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error aborting address change")
|
||||
@@ -514,25 +394,6 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncContactConnection(force: Bool) {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, force)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateContactConnectionStats(contact, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
||||
@@ -553,15 +414,6 @@ func abortSwitchAddressAlert(_ abortSwitchAddress: @escaping () -> Void) -> Aler
|
||||
)
|
||||
}
|
||||
|
||||
func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Alert {
|
||||
Alert(
|
||||
title: Text("Renegotiate encryption?"),
|
||||
message: Text("The encryption is working and the new encryption agreement is not required. It may result in connection errors!"),
|
||||
primaryButton: .destructive(Text("Renegotiate"), action: syncConnectionForce),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
struct ChatInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatInfoView(
|
||||
|
||||
@@ -10,11 +10,20 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIEventView: View {
|
||||
var eventText: Text
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
eventText
|
||||
if let member = chatItem.memberDisplayName {
|
||||
Text(member)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
+ Text(" ")
|
||||
+ chatEventText(chatItem)
|
||||
} else {
|
||||
chatEventText(chatItem)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
@@ -22,8 +31,20 @@ struct CIEventView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func chatEventText(_ ci: ChatItem) -> Text {
|
||||
Text(ci.content.text)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
+ Text(" ")
|
||||
+ ci.timestampText
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.secondary)
|
||||
.fontWeight(.light)
|
||||
}
|
||||
|
||||
struct CIEventView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CIEventView(eventText: Text("event happened"))
|
||||
CIEventView(chatItem: ChatItem.getGroupEventSample())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ struct CIFileView: View {
|
||||
|
||||
var body: some View {
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
@@ -62,7 +62,6 @@ struct CIFileView: View {
|
||||
case .rcvComplete: return true
|
||||
case .rcvCancelled: return false
|
||||
case .rcvError: return false
|
||||
case .invalid: return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -84,8 +83,7 @@ struct CIFileView: View {
|
||||
Task {
|
||||
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
||||
await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted)
|
||||
await receiveFile(user: user, fileId: file.fileId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -110,8 +108,9 @@ struct CIFileView: View {
|
||||
}
|
||||
case .rcvComplete:
|
||||
logger.debug("CIFileView fileAction - in .rcvComplete")
|
||||
if let fileSource = getLoadedFileSource(file) {
|
||||
saveCryptoFile(fileSource)
|
||||
if let filePath = getLoadedFilePath(file) {
|
||||
let url = URL(fileURLWithPath: filePath)
|
||||
showShareSheet(items: [url])
|
||||
}
|
||||
default: break
|
||||
}
|
||||
@@ -150,7 +149,6 @@ struct CIFileView: View {
|
||||
case .rcvComplete: fileIcon("doc.fill")
|
||||
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
||||
}
|
||||
} else {
|
||||
fileIcon("doc.fill")
|
||||
@@ -193,35 +191,11 @@ struct CIFileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func saveCryptoFile(_ fileSource: CryptoFile) {
|
||||
if let cfArgs = fileSource.cryptoArgs {
|
||||
let url = getAppFilePath(fileSource.filePath)
|
||||
let tempUrl = getTempFilesDirectory().appendingPathComponent(fileSource.filePath)
|
||||
Task {
|
||||
do {
|
||||
try decryptCryptoFile(fromPath: url.path, cryptoArgs: cfArgs, toPath: tempUrl.path)
|
||||
await MainActor.run {
|
||||
showShareSheet(items: [tempUrl]) {
|
||||
removeFile(tempUrl)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
AlertManager.shared.showAlertMsg(title: "Error decrypting file", message: "Error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let url = getAppFilePath(fileSource.filePath)
|
||||
showShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
|
||||
struct CIFileView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentFile: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .file("")),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
|
||||
@@ -16,7 +16,6 @@ struct CIImageView: View {
|
||||
let maxWidth: CGFloat
|
||||
@Binding var imgWidth: CGFloat?
|
||||
@State var scrollProxy: ScrollViewProxy?
|
||||
@State var metaColor: Color
|
||||
@State private var showFullScreenImage = false
|
||||
|
||||
var body: some View {
|
||||
@@ -37,8 +36,9 @@ struct CIImageView: View {
|
||||
case .rcvInvitation:
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile)
|
||||
await receiveFile(user: user, fileId: file.fileId)
|
||||
}
|
||||
// TODO image accepted alert?
|
||||
}
|
||||
case .rcvAccepted:
|
||||
switch file.fileProtocol {
|
||||
@@ -99,7 +99,6 @@ struct CIImageView: View {
|
||||
case .rcvTransfer: progressView()
|
||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||
case .rcvError: fileIcon("xmark", 10, 13)
|
||||
case .invalid: fileIcon("questionmark", 10, 13)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
@@ -110,7 +109,7 @@ struct CIImageView: View {
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(metaColor)
|
||||
.foregroundColor(.white)
|
||||
.padding(padding)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
//
|
||||
// CIMemberCreatedContactView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 19.09.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIMemberCreatedContactView: View {
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
switch chatItem.chatDir {
|
||||
case let .groupRcv(groupMember):
|
||||
if let contactId = groupMember.memberContactId {
|
||||
memberCreatedContactView(openText: "Open")
|
||||
.onTapGesture {
|
||||
dismissAllSheets(animated: true)
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.chatId = "@\(contactId)"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
memberCreatedContactView()
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
private func memberCreatedContactView(openText: LocalizedStringKey? = nil) -> some View {
|
||||
var r = eventText()
|
||||
if let openText {
|
||||
r = r
|
||||
+ Text(openText)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.accentColor)
|
||||
+ Text(" ")
|
||||
}
|
||||
r = r + chatItem.timestampText
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
return r.font(.caption)
|
||||
}
|
||||
|
||||
private func eventText() -> Text {
|
||||
if let member = chatItem.memberDisplayName {
|
||||
return Text(member + " " + chatItem.content.text + " ")
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
return Text(chatItem.content.text + " ")
|
||||
.fontWeight(.light)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CIMemberCreatedContactView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let content = CIContent.rcvGroupEvent(rcvGroupEvent: .memberCreatedContact)
|
||||
let chatItem = ChatItem(
|
||||
chatDir: .groupRcv(groupMember: GroupMember.sampleData),
|
||||
meta: CIMeta.getSample(1, .now, content.text, .rcvRead),
|
||||
content: content,
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
CIMemberCreatedContactView(chatItem: chatItem)
|
||||
}
|
||||
}
|
||||
@@ -13,48 +13,17 @@ struct CIMetaView: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var metaColor = Color.secondary
|
||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||
|
||||
var body: some View {
|
||||
if chatItem.isDeletedContent {
|
||||
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
|
||||
} else {
|
||||
let meta = chatItem.meta
|
||||
let ttl = chat.chatInfo.timedMessagesTTL
|
||||
let encrypted = chatItem.encryptedFile
|
||||
switch meta.itemStatus {
|
||||
case let .sndSent(sndProgress):
|
||||
switch sndProgress {
|
||||
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent)
|
||||
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent)
|
||||
}
|
||||
case let .sndRcvd(_, sndProgress):
|
||||
switch sndProgress {
|
||||
case .complete:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1)
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2)
|
||||
}
|
||||
case .partial:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1)
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2)
|
||||
}
|
||||
}
|
||||
default:
|
||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor)
|
||||
}
|
||||
ciMetaText(chatItem.meta, chatTTL: chat.chatInfo.timedMessagesTTL, color: metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SentCheckmark {
|
||||
case sent
|
||||
case rcvd1
|
||||
case rcvd2
|
||||
}
|
||||
|
||||
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text {
|
||||
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparent: Bool = false) -> Text {
|
||||
var r = Text("")
|
||||
if meta.itemEdited {
|
||||
r = r + statusIconText("pencil", color)
|
||||
@@ -68,24 +37,11 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color =
|
||||
r = r + Text(" ")
|
||||
}
|
||||
if let (icon, statusColor) = meta.statusIcon(color) {
|
||||
let t = Text(Image(systemName: icon)).font(.caption2)
|
||||
let gap = Text(" ").kerning(-1.25)
|
||||
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
||||
switch sent {
|
||||
case nil: r = r + t1
|
||||
case .sent: r = r + t1 + gap
|
||||
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
|
||||
case .rcvd2: r = r + gap + t1
|
||||
}
|
||||
r = r + Text(" ")
|
||||
r = r + statusIconText(icon, transparent ? .clear : statusColor) + Text(" ")
|
||||
} else if !meta.disappearing {
|
||||
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
|
||||
}
|
||||
if let enc = encrypted {
|
||||
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
|
||||
}
|
||||
r = r + meta.timestampText.foregroundColor(color)
|
||||
return r.font(.caption)
|
||||
return (r + meta.timestampText.foregroundColor(color)).font(.caption)
|
||||
}
|
||||
|
||||
private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
||||
@@ -95,12 +51,8 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
||||
struct CIMetaView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, itemEdited: true))
|
||||
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 100))
|
||||
|
||||
@@ -12,204 +12,25 @@ import SimpleXChat
|
||||
let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup."
|
||||
|
||||
struct CIRcvDecryptionError: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
var msgDecryptError: MsgDecryptError
|
||||
var msgCount: UInt32
|
||||
var chatItem: ChatItem
|
||||
@State private var alert: CIRcvDecryptionErrorAlert?
|
||||
|
||||
enum CIRcvDecryptionErrorAlert: Identifiable {
|
||||
case syncAllowedAlert(_ syncConnection: () -> Void)
|
||||
case syncNotSupportedContactAlert
|
||||
case syncNotSupportedMemberAlert
|
||||
case decryptionErrorAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .syncAllowedAlert: return "syncAllowedAlert"
|
||||
case .syncNotSupportedContactAlert: return "syncNotSupportedContactAlert"
|
||||
case .syncNotSupportedMemberAlert: return "syncNotSupportedMemberAlert"
|
||||
case .decryptionErrorAlert: return "decryptionErrorAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
viewBody()
|
||||
.onAppear {
|
||||
// for direct chat ConnectionStats are populated on opening chat, see ChatView onAppear
|
||||
if case let .group(groupInfo) = chat.chatInfo,
|
||||
case let .groupRcv(groupMember) = chatItem.chatDir {
|
||||
do {
|
||||
let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
|
||||
if let s = stats {
|
||||
ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, member, s)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
CIMsgError(chatItem: chatItem, showMember: showMember) {
|
||||
var message: Text
|
||||
let why = Text(decryptErrorReason)
|
||||
let permanent = Text("This error is permanent for this connection, please re-connect.")
|
||||
switch msgDecryptError {
|
||||
case .ratchetHeader:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + Text("\n") + permanent
|
||||
case .tooManySkipped:
|
||||
message = Text("\(msgCount) messages skipped.") + Text("\n") + why + Text("\n") + permanent
|
||||
}
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case let .syncAllowedAlert(syncConnection): return syncAllowedAlert(syncConnection)
|
||||
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
|
||||
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
|
||||
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
let contactStats = contact.activeConn.connectionStats {
|
||||
if contactStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
||||
}
|
||||
} else if !contactStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedContactAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
} else if case let .group(groupInfo) = chat.chatInfo,
|
||||
case let .groupRcv(groupMember) = chatItem.chatDir,
|
||||
let modelMember = ChatModel.shared.groupMembers.first(where: { $0.id == groupMember.id }),
|
||||
let memberStats = modelMember.activeConn?.connectionStats {
|
||||
if memberStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
|
||||
}
|
||||
} else if !memberStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedMemberAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Decryption error"), message: message))
|
||||
}
|
||||
}
|
||||
|
||||
private func basicDecryptionErrorItem() -> some View {
|
||||
decryptionErrorItem { alert = .decryptionErrorAlert }
|
||||
}
|
||||
|
||||
private func decryptionErrorItemFixButton(syncSupported: Bool, _ onClick: @escaping (() -> Void)) -> some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
}
|
||||
(
|
||||
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ Text("Fix connection")
|
||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
private func decryptionErrorItem(_ onClick: @escaping (() -> Void)) -> some View {
|
||||
return ZStack(alignment: .bottomTrailing) {
|
||||
HStack {
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
private func message() -> Text {
|
||||
var message: Text
|
||||
let why = Text(decryptErrorReason)
|
||||
switch msgDecryptError {
|
||||
case .ratchetHeader:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
case .tooManySkipped:
|
||||
message = Text("\(msgCount) messages skipped.") + Text("\n") + why
|
||||
case .ratchetEarlier:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
case .other:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
private func syncMemberConnection(_ groupInfo: GroupInfo, _ member: GroupMember) {
|
||||
Task {
|
||||
do {
|
||||
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, mem, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncContactConnection(_ contact: Contact) {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, false)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContactConnectionStats(contact, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncAllowedAlert(_ syncConnection: @escaping () -> Void) -> Alert {
|
||||
Alert(
|
||||
title: Text("Fix connection?"),
|
||||
message: message(),
|
||||
primaryButton: .default(Text("Fix"), action: syncConnection),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//struct CIRcvDecryptionError_Previews: PreviewProvider {
|
||||
|
||||
@@ -22,7 +22,6 @@ struct CIVideoView: View {
|
||||
@State private var scrollProxy: ScrollViewProxy?
|
||||
@State private var preview: UIImage? = nil
|
||||
@State private var player: AVPlayer?
|
||||
@State private var fullPlayer: AVPlayer?
|
||||
@State private var url: URL?
|
||||
@State private var showFullScreenPlayer = false
|
||||
@State private var timeObserver: Any? = nil
|
||||
@@ -37,7 +36,6 @@ struct CIVideoView: View {
|
||||
self.scrollProxy = scrollProxy
|
||||
if let url = getLoadedVideo(chatItem.file) {
|
||||
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false))
|
||||
self._fullPlayer = State(initialValue: AVPlayer(url: url))
|
||||
self._url = State(initialValue: url)
|
||||
}
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
@@ -59,7 +57,7 @@ struct CIVideoView: View {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation:
|
||||
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
case .rcvAccepted:
|
||||
switch file.fileProtocol {
|
||||
case .xftp:
|
||||
@@ -85,7 +83,7 @@ struct CIVideoView: View {
|
||||
}
|
||||
if let file = file, case .rcvInvitation = file.fileStatus {
|
||||
Button {
|
||||
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
@@ -98,7 +96,6 @@ struct CIVideoView: View {
|
||||
DispatchQueue.main.async { videoWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
ZStack(alignment: .center) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete
|
||||
VideoPlayerView(player: player, url: url, showControls: false)
|
||||
.frame(width: w, height: w * preview.size.height / preview.size.width)
|
||||
.onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in
|
||||
@@ -116,9 +113,7 @@ struct CIVideoView: View {
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
case .paused:
|
||||
if canBePlayed {
|
||||
showFullScreenPlayer = true
|
||||
}
|
||||
showFullScreenPlayer = true
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
@@ -127,9 +122,8 @@ struct CIVideoView: View {
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
.disabled(!canBePlayed)
|
||||
}
|
||||
}
|
||||
loadingIndicator()
|
||||
@@ -218,7 +212,6 @@ struct CIVideoView: View {
|
||||
}
|
||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||
case .rcvError: fileIcon("xmark", 10, 13)
|
||||
case .invalid: fileIcon("questionmark", 10, 13)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
@@ -253,11 +246,10 @@ struct CIVideoView: View {
|
||||
.padding([.trailing, .top], 11)
|
||||
}
|
||||
|
||||
// TODO encrypt: where file size is checked?
|
||||
private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
|
||||
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64) async -> Void) {
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user, file.fileId, encrypted, false)
|
||||
await receiveFile(user, file.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,7 +257,8 @@ struct CIVideoView: View {
|
||||
private func fullScreenPlayer(_ url: URL) -> some View {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
VideoPlayer(player: fullPlayer)
|
||||
VideoPlayer(player: createFullScreenPlayerAndPlay(url)) {
|
||||
}
|
||||
.overlay(alignment: .topLeading, content: {
|
||||
Button(action: { showFullScreenPlayer = false },
|
||||
label: {
|
||||
@@ -288,29 +281,28 @@ struct CIVideoView: View {
|
||||
}
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
if let player = fullPlayer {
|
||||
player.play()
|
||||
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if let fullScreenTimeObserver = fullScreenTimeObserver {
|
||||
NotificationCenter.default.removeObserver(fullScreenTimeObserver)
|
||||
}
|
||||
fullScreenTimeObserver = nil
|
||||
fullPlayer?.pause()
|
||||
fullPlayer?.seek(to: CMTime.zero)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createFullScreenPlayerAndPlay(_ url: URL) -> AVPlayer {
|
||||
let player = AVPlayer(url: url)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
return player
|
||||
}
|
||||
|
||||
private func addObserver(_ player: AVPlayer, _ url: URL) {
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
|
||||
if let item = player.currentItem {
|
||||
|
||||
@@ -144,7 +144,6 @@ struct VoiceMessagePlayer: View {
|
||||
case .rcvComplete: playbackButton()
|
||||
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
}
|
||||
} else {
|
||||
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
@@ -159,8 +158,7 @@ struct VoiceMessagePlayer: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { it in
|
||||
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||
if let recordingFileName = getLoadedFileName(recordingFile), chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
@@ -175,8 +173,8 @@ struct VoiceMessagePlayer: View {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Button {
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
if let recordingFileName = getLoadedFileName(recordingFile) {
|
||||
startPlayback(recordingFileName)
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
@@ -220,7 +218,7 @@ struct VoiceMessagePlayer: View {
|
||||
Button {
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -252,8 +250,8 @@ struct VoiceMessagePlayer: View {
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func startPlayback(_ recordingSource: CryptoFile) {
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
|
||||
private func startPlayback(_ recordingFileName: String) {
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
onFinishPlayback: {
|
||||
@@ -261,7 +259,7 @@ struct VoiceMessagePlayer: View {
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
||||
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
|
||||
playbackState = .playing
|
||||
}
|
||||
}
|
||||
@@ -270,7 +268,7 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentVoiceMessage: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
|
||||
@@ -12,9 +12,13 @@ import SimpleXChat
|
||||
struct DeletedItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).fontWeight(.medium) + Text(": ")
|
||||
}
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
@@ -33,7 +37,10 @@ struct DeletedItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
DeletedItemView(chatItem: ChatItem.getDeletedContentSample())
|
||||
DeletedItemView(chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)))
|
||||
DeletedItemView(
|
||||
chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)),
|
||||
showMember: true
|
||||
)
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 200))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func emojiText(_ text: String) -> Text {
|
||||
struct EmojiItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
|
||||
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent))
|
||||
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
|
||||
@@ -75,14 +75,14 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sentVoiceMessage: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
let voiceMessageWithQuote: ChatItem = ChatItem(
|
||||
chatDir: .directSnd,
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
|
||||
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
|
||||
quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv),
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
|
||||
@@ -18,6 +18,7 @@ struct FramedItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@State var scrollProxy: ScrollViewProxy? = nil
|
||||
@State var msgWidth: CGFloat = 0
|
||||
@@ -56,7 +57,7 @@ struct FramedItemView: View {
|
||||
}
|
||||
}
|
||||
|
||||
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: framedMsgContentView)
|
||||
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: framedMsgContentView)
|
||||
.padding(chatItem.content.msgContent != nil ? 0 : 4)
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
@@ -67,7 +68,6 @@ struct FramedItemView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
.overlay(DetermineWidth())
|
||||
.accessibilityLabel("")
|
||||
}
|
||||
}
|
||||
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme))
|
||||
@@ -97,7 +97,7 @@ struct FramedItemView: View {
|
||||
} else {
|
||||
switch (chatItem.content.msgContent) {
|
||||
case let .image(text, image):
|
||||
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy, metaColor: metaColor)
|
||||
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy)
|
||||
.overlay(DetermineWidth())
|
||||
if text == "" && !chatItem.meta.isLive {
|
||||
Color.clear
|
||||
@@ -107,7 +107,7 @@ struct FramedItemView: View {
|
||||
value: .white
|
||||
)
|
||||
} else {
|
||||
ciMsgContentView(chatItem)
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
case let .video(text, image, duration):
|
||||
CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy)
|
||||
@@ -120,27 +120,27 @@ struct FramedItemView: View {
|
||||
value: .white
|
||||
)
|
||||
} else {
|
||||
ciMsgContentView(chatItem)
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
case let .voice(text, duration):
|
||||
FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
.overlay(DetermineWidth())
|
||||
if text != "" {
|
||||
ciMsgContentView(chatItem)
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
case let .file(text):
|
||||
ciFileView(chatItem, text)
|
||||
case let .link(_, preview):
|
||||
CILinkView(linkPreview: preview)
|
||||
ciMsgContentView(chatItem)
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
case let .unknown(_, text: text):
|
||||
if chatItem.file == nil {
|
||||
ciMsgContentView(chatItem)
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
} else {
|
||||
ciFileView(chatItem, text)
|
||||
}
|
||||
default:
|
||||
ciMsgContentView(chatItem)
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,27 +232,17 @@ struct FramedItemView: View {
|
||||
}
|
||||
|
||||
private func ciQuotedMsgView(_ qi: CIQuote) -> some View {
|
||||
Group {
|
||||
if let sender = qi.getSender(membership()) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(sender).font(.caption).foregroundColor(.secondary)
|
||||
ciQuotedMsgTextView(qi, lines: 2)
|
||||
}
|
||||
} else {
|
||||
ciQuotedMsgTextView(qi, lines: 3)
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
MsgContentView(
|
||||
text: qi.text,
|
||||
formattedText: qi.formattedText,
|
||||
sender: qi.getSender(membership())
|
||||
)
|
||||
.lineLimit(3)
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View {
|
||||
MsgContentView(text: qi.text, formattedText: qi.formattedText)
|
||||
.lineLimit(lines)
|
||||
.font(.subheadline)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
private func ciQuoteIconView(_ image: String) -> some View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
@@ -270,12 +260,13 @@ struct FramedItemView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View {
|
||||
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View {
|
||||
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
|
||||
let rtl = isRightToLeft(text)
|
||||
let v = MsgContentView(
|
||||
text: text,
|
||||
formattedText: text == "" ? [] : ci.formattedText,
|
||||
sender: showMember ? ci.memberDisplayName : nil,
|
||||
meta: ci.meta,
|
||||
rightToLeft: rtl
|
||||
)
|
||||
@@ -297,7 +288,7 @@ struct FramedItemView: View {
|
||||
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
|
||||
.overlay(DetermineWidth())
|
||||
if text != "" || ci.meta.isLive {
|
||||
ciMsgContentView (chatItem)
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,8 +349,8 @@ struct FramedItemView_Previews: PreviewProvider {
|
||||
Group{
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
@@ -372,10 +363,10 @@ struct FramedItemView_Previews: PreviewProvider {
|
||||
struct FramedItemView_Edited_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
@@ -390,10 +381,10 @@ struct FramedItemView_Edited_Previews: PreviewProvider {
|
||||
struct FramedItemView_Deleted_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
|
||||
@@ -12,9 +12,10 @@ import SimpleXChat
|
||||
struct IntegrityErrorItemView: View {
|
||||
var msgError: MsgErrorType
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
CIMsgError(chatItem: chatItem) {
|
||||
CIMsgError(chatItem: chatItem, showMember: showMember) {
|
||||
switch msgError {
|
||||
case .msgSkipped:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -53,10 +54,14 @@ struct IntegrityErrorItemView: View {
|
||||
|
||||
struct CIMsgError: View {
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
var onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).fontWeight(.medium) + Text(": ")
|
||||
}
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
|
||||
@@ -12,9 +12,13 @@ import SimpleXChat
|
||||
struct MarkedDeletedItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).font(.caption).fontWeight(.medium) + Text(": ").font(.caption)
|
||||
}
|
||||
if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted {
|
||||
markedDeletedText("moderated by \(byGroupMember.chatViewName)")
|
||||
} else {
|
||||
@@ -42,7 +46,7 @@ struct MarkedDeletedItemView: View {
|
||||
struct MarkedDeletedItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
|
||||
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 200))
|
||||
}
|
||||
|
||||
@@ -80,14 +80,14 @@ struct MsgContentView: View {
|
||||
}
|
||||
|
||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true)
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, transparent: true)
|
||||
}
|
||||
}
|
||||
|
||||
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false) -> Text {
|
||||
let s = text
|
||||
var res: Text
|
||||
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
||||
if let ft = formattedText, ft.count > 0 {
|
||||
res = formatText(ft[0], preview)
|
||||
var i = 1
|
||||
while i < ft.count {
|
||||
|
||||
@@ -13,25 +13,7 @@ struct ChatItemInfoView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var ci: ChatItem
|
||||
@Binding var chatItemInfo: ChatItemInfo?
|
||||
@State private var selection: CIInfoTab = .history
|
||||
@State private var alert: CIInfoViewAlert? = nil
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum CIInfoTab {
|
||||
case history
|
||||
case quote
|
||||
case delivery
|
||||
}
|
||||
|
||||
enum CIInfoViewAlert: Identifiable {
|
||||
case alert(title: String, text: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .alert(title, text): return "alert \(title) \(text)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
@@ -43,11 +25,6 @@ struct ChatItemInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch(a) {
|
||||
case let .alert(title, text): return Alert(title: Text(title), message: Text(text))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,92 +34,44 @@ struct ChatItemInfoView: View {
|
||||
: NSLocalizedString("Received message", comment: "message info title")
|
||||
}
|
||||
|
||||
private var numTabs: Int {
|
||||
var numTabs = 1
|
||||
if chatItemInfo?.memberDeliveryStatuses != nil {
|
||||
numTabs += 1
|
||||
}
|
||||
if ci.quotedItem != nil {
|
||||
numTabs += 1
|
||||
}
|
||||
return numTabs
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemInfoView() -> some View {
|
||||
if numTabs > 1 {
|
||||
TabView(selection: $selection) {
|
||||
if let mdss = chatItemInfo?.memberDeliveryStatuses {
|
||||
deliveryTab(mdss)
|
||||
.tabItem {
|
||||
Label("Delivery", systemImage: "checkmark.message")
|
||||
}
|
||||
.tag(CIInfoTab.delivery)
|
||||
}
|
||||
historyTab()
|
||||
.tabItem {
|
||||
Label("History", systemImage: "clock")
|
||||
}
|
||||
.tag(CIInfoTab.history)
|
||||
if let qi = ci.quotedItem {
|
||||
quoteTab(qi)
|
||||
.tabItem {
|
||||
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
.tag(CIInfoTab.quote)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if chatItemInfo?.memberDeliveryStatuses != nil {
|
||||
selection = .delivery
|
||||
}
|
||||
}
|
||||
} else {
|
||||
historyTab()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func details() -> some View {
|
||||
let meta = ci.meta
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(title)
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
|
||||
infoRow("Sent at", localTimestamp(meta.itemTs))
|
||||
if !ci.chatDir.sent {
|
||||
infoRow("Received at", localTimestamp(meta.createdAt))
|
||||
}
|
||||
switch (meta.itemDeleted) {
|
||||
case let .deleted(deletedTs):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Deleted at", localTimestamp(deletedTs))
|
||||
}
|
||||
case let .moderated(deletedTs, _):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Moderated at", localTimestamp(deletedTs))
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
if let deleteAt = meta.itemTimed?.deleteAt {
|
||||
infoRow("Disappears at", localTimestamp(deleteAt))
|
||||
}
|
||||
if developerTools {
|
||||
infoRow("Database ID", "\(meta.itemId)")
|
||||
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func historyTab() -> some View {
|
||||
GeometryReader { g in
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
Divider().padding(.vertical)
|
||||
Text(title)
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
infoRow("Sent at", localTimestamp(meta.itemTs))
|
||||
if !ci.chatDir.sent {
|
||||
infoRow("Received at", localTimestamp(meta.createdAt))
|
||||
}
|
||||
switch (meta.itemDeleted) {
|
||||
case let .deleted(deletedTs):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Deleted at", localTimestamp(deletedTs))
|
||||
}
|
||||
case let .moderated(deletedTs, _):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Moderated at", localTimestamp(deletedTs))
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
if let deleteAt = meta.itemTimed?.deleteAt {
|
||||
infoRow("Disappears at", localTimestamp(deleteAt))
|
||||
}
|
||||
if developerTools {
|
||||
infoRow("Database ID", "\(meta.itemId)")
|
||||
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
||||
}
|
||||
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
Divider().padding(.vertical)
|
||||
|
||||
Text("History")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 4)
|
||||
@@ -152,21 +81,16 @@ struct ChatItemInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Text("No history")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||
versionText(itemVersion)
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
@@ -195,9 +119,9 @@ struct ChatItemInfoView: View {
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
|
||||
if text != "" {
|
||||
messageText(text, formattedText, sender)
|
||||
@ViewBuilder private func versionText(_ itemVersion: ChatItemVersion) -> some View {
|
||||
if itemVersion.msgContent.text != "" {
|
||||
messageText(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||
} else {
|
||||
Text("no text")
|
||||
.italic()
|
||||
@@ -205,141 +129,9 @@ struct ChatItemInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View {
|
||||
GeometryReader { g in
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
Divider().padding(.vertical)
|
||||
Text("In reply to")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 4)
|
||||
quotedMsgView(qi, maxWidth)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(quotedMsgFrameColor(qi, colorScheme))
|
||||
.cornerRadius(18)
|
||||
.contextMenu {
|
||||
if qi.text != "" {
|
||||
Button {
|
||||
showShareSheet(items: [qi.text])
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Button {
|
||||
UIPasteboard.general.string = qi.text
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(localTimestamp(qi.sentAt))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
|
||||
(qi.chatDir?.sent ?? false)
|
||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
}
|
||||
|
||||
@ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
Divider().padding(.vertical)
|
||||
Text("Delivery")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 4)
|
||||
memberDeliveryStatusesView(memberDeliveryStatuses)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
let mss = membersStatuses(memberDeliveryStatuses)
|
||||
if !mss.isEmpty {
|
||||
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
||||
memberDeliveryStatusView(memberStatus.0, memberStatus.1)
|
||||
}
|
||||
} else {
|
||||
Text("No delivery information")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
|
||||
memberDeliveryStatuses.compactMap({ mds in
|
||||
if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) {
|
||||
return (mem, mds.memberDeliveryStatus)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View {
|
||||
HStack{
|
||||
ProfileImage(imageStr: member.image)
|
||||
.frame(width: 30, height: 30)
|
||||
.padding(.trailing, 2)
|
||||
Text(member.chatViewName)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
let v = Group {
|
||||
if let (icon, statusColor) = status.statusIcon(Color.secondary) {
|
||||
switch status {
|
||||
case .sndRcvd:
|
||||
ZStack(alignment: .trailing) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
.padding(.trailing, 6)
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
}
|
||||
default:
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundColor(Color.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let (title, text) = status.statusInfo {
|
||||
v.onTapGesture {
|
||||
alert = .alert(title: title, text: text)
|
||||
}
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func itemInfoShareText() -> String {
|
||||
let meta = ci.meta
|
||||
var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""]
|
||||
var shareText: [String] = [title, ""]
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Sent at: %@", comment: "copied message info"), localTimestamp(meta.itemTs))]
|
||||
if !ci.chatDir.sent {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
|
||||
@@ -364,27 +156,9 @@ struct ChatItemInfoView: View {
|
||||
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt))
|
||||
]
|
||||
}
|
||||
if let qi = ci.quotedItem {
|
||||
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
|
||||
let t = qi.text
|
||||
shareText += [""]
|
||||
if let sender = qi.getSender(nil) {
|
||||
shareText += [String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@ at %@:", comment: "copied message info, <sender> at <time>"),
|
||||
sender,
|
||||
localTimestamp(qi.sentAt)
|
||||
)]
|
||||
} else {
|
||||
shareText += [String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@:", comment: "copied message info"),
|
||||
localTimestamp(qi.sentAt)
|
||||
)]
|
||||
}
|
||||
shareText += [t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")]
|
||||
}
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
shareText += ["", NSLocalizedString("## History", comment: "copied message info")]
|
||||
shareText += ["", NSLocalizedString("History", comment: "copied message info")]
|
||||
for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() {
|
||||
let t = itemVersion.msgContent.text
|
||||
shareText += [
|
||||
|
||||
@@ -12,6 +12,7 @@ import SimpleXChat
|
||||
struct ChatItemView: View {
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@State var scrollProxy: ScrollViewProxy? = nil
|
||||
@Binding var revealed: Bool
|
||||
@@ -22,6 +23,7 @@ struct ChatItemView: View {
|
||||
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding<Bool>, allowMenu: Binding<Bool> = .constant(false), audioPlayer: Binding<AudioPlayer?> = .constant(nil), playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback), playbackTime: Binding<TimeInterval?> = .constant(nil)) {
|
||||
self.chatInfo = chatInfo
|
||||
self.chatItem = chatItem
|
||||
self.showMember = showMember
|
||||
self.maxWidth = maxWidth
|
||||
_scrollProxy = .init(initialValue: scrollProxy)
|
||||
_revealed = revealed
|
||||
@@ -34,14 +36,14 @@ struct ChatItemView: View {
|
||||
var body: some View {
|
||||
let ci = chatItem
|
||||
if chatItem.meta.itemDeleted != nil && !revealed {
|
||||
MarkedDeletedItemView(chatItem: chatItem)
|
||||
MarkedDeletedItemView(chatItem: chatItem, showMember: showMember)
|
||||
} else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
|
||||
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||
EmojiItemView(chatItem: ci)
|
||||
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
||||
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
|
||||
} else if ci.content.msgContent == nil {
|
||||
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||
} else {
|
||||
framedItemView()
|
||||
}
|
||||
@@ -51,16 +53,15 @@ struct ChatItemView: View {
|
||||
}
|
||||
|
||||
private func framedItemView() -> some View {
|
||||
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatItemContentView<Content: View>: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
var showMember: Bool
|
||||
var msgContentView: () -> Content
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
var body: some View {
|
||||
switch chatItem.content {
|
||||
@@ -70,17 +71,10 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case .rcvDeleted: deletedItemView()
|
||||
case let .sndCall(status, duration): callItemView(status, duration)
|
||||
case let .rcvCall(status, duration): callItemView(status, duration)
|
||||
case let .rcvIntegrityError(msgError):
|
||||
if developerTools {
|
||||
IntegrityErrorItemView(msgError: msgError, chatItem: chatItem)
|
||||
} else {
|
||||
ZStack {}
|
||||
}
|
||||
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
|
||||
case let .rcvIntegrityError(msgError): IntegrityErrorItemView(msgError: msgError, chatItem: chatItem, showMember: showMember)
|
||||
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem, showMember: showMember)
|
||||
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText)
|
||||
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
|
||||
case .rcvGroupEvent: eventItemView()
|
||||
case .sndGroupEvent: eventItemView()
|
||||
case .rcvConnEvent: eventItemView()
|
||||
@@ -102,7 +96,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
}
|
||||
|
||||
private func deletedItemView() -> some View {
|
||||
DeletedItemView(chatItem: chatItem)
|
||||
DeletedItemView(chatItem: chatItem, showMember: showMember)
|
||||
}
|
||||
|
||||
private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View {
|
||||
@@ -114,54 +108,12 @@ struct ChatItemContentView<Content: View>: View {
|
||||
}
|
||||
|
||||
private func eventItemView() -> some View {
|
||||
return CIEventView(eventText: eventItemViewText())
|
||||
}
|
||||
|
||||
private func eventItemViewText() -> Text {
|
||||
if let member = chatItem.memberDisplayName {
|
||||
return Text(member + " ")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
+ chatEventText(chatItem)
|
||||
} else {
|
||||
return chatEventText(chatItem)
|
||||
}
|
||||
CIEventView(chatItem: chatItem)
|
||||
}
|
||||
|
||||
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
|
||||
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
|
||||
}
|
||||
|
||||
private var membersConnectedItemText: Text {
|
||||
if let t = membersConnectedText {
|
||||
return chatEventText(t, chatItem.timestampText)
|
||||
} else {
|
||||
return eventItemViewText()
|
||||
}
|
||||
}
|
||||
|
||||
private var membersConnectedText: LocalizedStringKey? {
|
||||
let ns = chatModel.getConnectedMemberNames(chatItem)
|
||||
return ns.count > 3
|
||||
? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected"
|
||||
: ns.count == 3
|
||||
? "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected"
|
||||
: ns.count == 2
|
||||
? "\(ns[0]) and \(ns[1]) connected"
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
|
||||
(Text(eventText) + Text(" ") + ts)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
}
|
||||
|
||||
func chatEventText(_ ci: ChatItem) -> Text {
|
||||
chatEventText("\(ci.content.text)", ci.timestampText)
|
||||
}
|
||||
|
||||
struct ChatItemView_Previews: PreviewProvider {
|
||||
@@ -173,9 +125,9 @@ struct ChatItemView_Previews: PreviewProvider {
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent, itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemLive: true), revealed: Binding.constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
.environmentObject(Chat.sampleData)
|
||||
|
||||
@@ -22,7 +22,7 @@ struct ChatView: View {
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var composeState = ComposeState()
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@State private var keyboardVisible = false
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var showDeleteMessage = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var customUserProfile: Profile?
|
||||
@@ -39,16 +39,6 @@ struct ChatView: View {
|
||||
@State private var selectedMember: GroupMember? = nil
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.keyboardPadding()
|
||||
} else {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
|
||||
private var viewBody: some View {
|
||||
let cInfo = chat.chatInfo
|
||||
return VStack(spacing: 0) {
|
||||
if searchMode {
|
||||
@@ -64,7 +54,6 @@ struct ChatView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
connectingText()
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
@@ -76,14 +65,17 @@ struct ChatView: View {
|
||||
.navigationTitle(cInfo.chatViewName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
initChatView()
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { cId in
|
||||
if cId != nil {
|
||||
initChatView()
|
||||
} else {
|
||||
dismiss()
|
||||
if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft {
|
||||
composeState = draft
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
Task {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
if chatModel.chatId == nil { dismiss() }
|
||||
}
|
||||
.onDisappear {
|
||||
VideoPlayerView.players.removeAll()
|
||||
@@ -150,7 +142,6 @@ struct ChatView: View {
|
||||
HStack {
|
||||
if contact.allowsFeature(.calls) {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready)
|
||||
}
|
||||
Menu {
|
||||
if contact.allowsFeature(.calls) {
|
||||
@@ -159,11 +150,9 @@ struct ChatView: View {
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
.disabled(!contact.ready)
|
||||
}
|
||||
searchButton()
|
||||
toggleNtfsButton(chat)
|
||||
.disabled(!contact.ready)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
@@ -196,32 +185,6 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func initChatView() {
|
||||
let cInfo = chat.chatInfo
|
||||
if case let .direct(contact) = cInfo {
|
||||
Task {
|
||||
do {
|
||||
let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId)
|
||||
await MainActor.run {
|
||||
if let s = stats {
|
||||
chatModel.updateContactConnectionStats(contact, s)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiContactInfo error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft {
|
||||
composeState = draft
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
Task {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func searchToolbar() -> some View {
|
||||
HStack {
|
||||
HStack {
|
||||
@@ -265,7 +228,7 @@ struct ChatView: View {
|
||||
return GeometryReader { g in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
LazyVStack(spacing: 5) {
|
||||
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
|
||||
let voiceNoFrame = voiceWithoutFrame(ci)
|
||||
let maxWidth = cInfo.chatType == .group
|
||||
@@ -317,19 +280,6 @@ struct ChatView: View {
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||
}
|
||||
|
||||
@ViewBuilder private func connectingText() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
!contact.ready,
|
||||
!contact.nextSendGrpInv {
|
||||
Text("connecting…")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
|
||||
let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
|
||||
@@ -447,77 +397,68 @@ struct ChatView: View {
|
||||
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
|
||||
if case let .groupRcv(member) = ci.chatDir,
|
||||
case let .group(groupInfo) = chat.chatInfo {
|
||||
let (prevItem, nextItem) = chatModel.getChatItemNeighbors(ci)
|
||||
if ci.memberConnected != nil && nextItem?.memberConnected != nil {
|
||||
// memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView
|
||||
ZStack {} // scroll doesn't work if it's EmptyView()
|
||||
} else {
|
||||
if prevItem == nil || showMemberImage(member, prevItem) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if ci.content.showMemberName {
|
||||
Text(member.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, memberImageSize + 14)
|
||||
.padding(.top, 7)
|
||||
let prevItem = chatModel.getPrevChatItem(ci)
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let showMember = prevItem == nil || showMemberImage(member, prevItem)
|
||||
if showMember {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
.onTapGesture { selectedMember = member }
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
.onTapGesture { selectedMember = member }
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
|
||||
}
|
||||
chatItemWithMenu(ci, maxWidth)
|
||||
}
|
||||
}
|
||||
.padding(.top, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
chatItemWithMenu(ci, maxWidth)
|
||||
.padding(.top, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, memberImageSize + 8 + 12)
|
||||
Rectangle().fill(.clear)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
}
|
||||
ChatItemWithMenu(
|
||||
ci: ci,
|
||||
showMember: showMember,
|
||||
maxWidth: maxWidth,
|
||||
scrollProxy: scrollProxy,
|
||||
deleteMessage: deleteMessage,
|
||||
deletingItem: $deletingItem,
|
||||
composeState: $composeState,
|
||||
showDeleteMessage: $showDeleteMessage
|
||||
)
|
||||
.padding(.leading, 8)
|
||||
.environmentObject(chat)
|
||||
}
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
chatItemWithMenu(ci, maxWidth)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 5)
|
||||
ChatItemWithMenu(
|
||||
ci: ci,
|
||||
maxWidth: maxWidth,
|
||||
scrollProxy: scrollProxy,
|
||||
deleteMessage: deleteMessage,
|
||||
deletingItem: $deletingItem,
|
||||
composeState: $composeState,
|
||||
showDeleteMessage: $showDeleteMessage
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.environmentObject(chat)
|
||||
}
|
||||
}
|
||||
|
||||
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
|
||||
ChatItemWithMenu(
|
||||
ci: ci,
|
||||
maxWidth: maxWidth,
|
||||
scrollProxy: scrollProxy,
|
||||
deleteMessage: deleteMessage,
|
||||
deletingItem: $deletingItem,
|
||||
composeState: $composeState,
|
||||
showDeleteMessage: $showDeleteMessage
|
||||
)
|
||||
.environmentObject(chat)
|
||||
}
|
||||
|
||||
|
||||
private struct ChatItemWithMenu: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var ci: ChatItem
|
||||
var showMember: Bool = false
|
||||
var maxWidth: CGFloat
|
||||
var scrollProxy: ScrollViewProxy?
|
||||
var deleteMessage: (CIDeleteMode) -> Void
|
||||
@Binding var deletingItem: ChatItem?
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var showDeleteMessage: Bool
|
||||
|
||||
|
||||
@State private var revealed = false
|
||||
@State private var showChatItemInfoSheet: Bool = false
|
||||
@State private var chatItemInfo: ChatItemInfo?
|
||||
|
||||
|
||||
@State private var allowMenu: Bool = true
|
||||
|
||||
|
||||
@State private var audioPlayer: AudioPlayer?
|
||||
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State private var playbackTime: TimeInterval?
|
||||
@@ -530,9 +471,8 @@ struct ChatView: View {
|
||||
)
|
||||
|
||||
VStack(alignment: alignment.horizontal, spacing: 3) {
|
||||
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
|
||||
.accessibilityLabel("")
|
||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||
chatItemReactions()
|
||||
.padding(.bottom, 4)
|
||||
@@ -618,15 +558,15 @@ struct ChatView: View {
|
||||
}
|
||||
menu.append(shareUIAction())
|
||||
menu.append(copyUIAction())
|
||||
if let fileSource = getLoadedFileSource(ci.file) {
|
||||
if let filePath = getLoadedFilePath(ci.file) {
|
||||
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
||||
if image.imageData != nil {
|
||||
menu.append(saveFileAction(fileSource))
|
||||
menu.append(saveFileAction(filePath))
|
||||
} else {
|
||||
menu.append(saveImageAction(image))
|
||||
}
|
||||
} else {
|
||||
menu.append(saveFileAction(fileSource))
|
||||
menu.append(saveFileAction(filePath))
|
||||
}
|
||||
}
|
||||
if ci.meta.editable && !mc.isVoice && !live {
|
||||
@@ -654,7 +594,6 @@ struct ChatView: View {
|
||||
menu.append(viewInfoUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
} else if ci.isDeletedContent {
|
||||
menu.append(viewInfoUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
}
|
||||
return menu
|
||||
@@ -677,7 +616,7 @@ struct ChatView: View {
|
||||
|
||||
private func reactionUIMenuPreiOS16(_ rs: [UIAction]) -> UIMenu {
|
||||
UIMenu(
|
||||
title: NSLocalizedString("React…", comment: "chat item menu"),
|
||||
title: NSLocalizedString("React...", comment: "chat item menu"),
|
||||
image: UIImage(systemName: "face.smiling"),
|
||||
children: rs
|
||||
)
|
||||
@@ -764,12 +703,13 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveFileAction(_ fileSource: CryptoFile) -> UIAction {
|
||||
private func saveFileAction(_ filePath: String) -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Save", comment: "chat item action"),
|
||||
image: UIImage(systemName: fileSource.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open")
|
||||
image: UIImage(systemName: "square.and.arrow.down")
|
||||
) { _ in
|
||||
saveCryptoFile(fileSource)
|
||||
let fileURL = URL(fileURLWithPath: filePath)
|
||||
showShareSheet(items: [fileURL])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,12 +736,6 @@ struct ChatView: View {
|
||||
await MainActor.run {
|
||||
chatItemInfo = ciInfo
|
||||
}
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGetChatItemInfo error: \(responseError(error))")
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ struct ComposeState {
|
||||
var contextItem: ComposeContextItem
|
||||
var voiceMessageRecordingState: VoiceMessageRecordingState
|
||||
var inProgress = false
|
||||
var disabled = false
|
||||
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||
|
||||
init(
|
||||
@@ -167,23 +168,25 @@ struct ComposeState {
|
||||
}
|
||||
|
||||
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
let chatItemPreview: ComposePreview
|
||||
switch chatItem.content.msgContent {
|
||||
case .text:
|
||||
return .noPreview
|
||||
chatItemPreview = .noPreview
|
||||
case let .link(_, preview: preview):
|
||||
return .linkPreview(linkPreview: preview)
|
||||
chatItemPreview = .linkPreview(linkPreview: preview)
|
||||
case let .image(_, image):
|
||||
return .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
case let .video(_, image, _):
|
||||
return .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
case let .voice(_, duration):
|
||||
return .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
|
||||
chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
|
||||
case .file:
|
||||
let fileName = chatItem.file?.fileName ?? ""
|
||||
return .filePreview(fileName: fileName, file: getAppFilePath(fileName))
|
||||
chatItemPreview = .filePreview(fileName: fileName, file: getAppFilePath(fileName))
|
||||
default:
|
||||
return .noPreview
|
||||
chatItemPreview = .noPreview
|
||||
}
|
||||
return chatItemPreview
|
||||
}
|
||||
|
||||
enum UploadContent: Equatable {
|
||||
@@ -231,14 +234,13 @@ struct ComposeView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var keyboardVisible: Bool
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
|
||||
@State var linkUrl: URL? = nil
|
||||
@State var prevLinkUrl: URL? = nil
|
||||
@State var pendingLinkUrl: URL? = nil
|
||||
@State var cancelledLinks: Set<String> = []
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var showChooseSource = false
|
||||
@State private var showMediaPicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@@ -253,13 +255,8 @@ struct ComposeView: View {
|
||||
// this is a workaround to fire an explicit event in certain cases
|
||||
@State private var stopPlayback: Bool = false
|
||||
|
||||
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||
ContextInvitingContactMemberView()
|
||||
}
|
||||
contextItemView()
|
||||
switch (composeState.editing, composeState.preview) {
|
||||
case (true, .filePreview): EmptyView()
|
||||
@@ -273,7 +270,7 @@ struct ComposeView: View {
|
||||
Image(systemName: "paperclip")
|
||||
.resizable()
|
||||
}
|
||||
.disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
|
||||
.disabled(composeState.attachmentDisabled || !chat.userCanSend)
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.leading, 12)
|
||||
@@ -301,7 +298,6 @@ struct ComposeView: View {
|
||||
composeState.liveMessage = nil
|
||||
chatModel.removeLiveDummy()
|
||||
},
|
||||
nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
|
||||
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
|
||||
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
||||
startVoiceMessageRecording: {
|
||||
@@ -313,10 +309,7 @@ struct ComposeView: View {
|
||||
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
|
||||
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
|
||||
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
|
||||
keyboardVisible: $keyboardVisible,
|
||||
sendButtonColor: chat.chatInfo.incognito
|
||||
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
||||
: .accentColor
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
.background(.background)
|
||||
@@ -449,15 +442,7 @@ struct ComposeView: View {
|
||||
} else if (composeState.inProgress) {
|
||||
clearCurrentDraft()
|
||||
} else if !composeState.empty {
|
||||
if case .recording = composeState.voiceMessageRecordingState {
|
||||
finishVoiceMessageRecording()
|
||||
if let fileName = composeState.voiceMessageRecordingFileName {
|
||||
chatModel.filesToDelete.insert(getAppFilePath(fileName))
|
||||
}
|
||||
}
|
||||
if saveLastDraft {
|
||||
saveCurrentDraft()
|
||||
}
|
||||
saveCurrentDraft()
|
||||
} else {
|
||||
cancelCurrentVoiceRecording()
|
||||
clearCurrentDraft()
|
||||
@@ -621,9 +606,7 @@ struct ComposeView: View {
|
||||
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
|
||||
await sending()
|
||||
}
|
||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||
await sendMemberContactInvitation()
|
||||
} else if case let .editingItem(ci) = composeState.contextItem {
|
||||
if case let .editingItem(ci) = composeState.contextItem {
|
||||
sent = await updateMessage(ci, live: live)
|
||||
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
|
||||
sent = await updateMessage(liveMessage.chatItem, live: live)
|
||||
@@ -660,10 +643,10 @@ struct ComposeView: View {
|
||||
}
|
||||
case let .voicePreview(recordingFileName, duration):
|
||||
stopPlayback.toggle()
|
||||
let file = voiceCryptoFile(recordingFileName)
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
|
||||
chatModel.filesToDelete.remove(getAppFilePath(recordingFileName))
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName, ttl: ttl)
|
||||
case let .filePreview(_, file):
|
||||
if let savedFile = saveFileFromURL(file, encrypted: privacyEncryptLocalFilesGroupDefault.get()) {
|
||||
if let savedFile = saveFileFromURL(file) {
|
||||
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
}
|
||||
@@ -672,19 +655,9 @@ struct ComposeView: View {
|
||||
return sent
|
||||
|
||||
func sending() async {
|
||||
await MainActor.run { composeState.inProgress = true }
|
||||
}
|
||||
|
||||
func sendMemberContactInvitation() async {
|
||||
do {
|
||||
let mc = checkLinkPreview()
|
||||
let contact = try await apiSendMemberContactInvitation(chat.chatInfo.apiId, mc)
|
||||
await MainActor.run {
|
||||
self.chatModel.updateContact(contact)
|
||||
}
|
||||
} catch {
|
||||
logger.error("ChatView.sendMemberContactInvitation error: \(error.localizedDescription)")
|
||||
AlertManager.shared.showAlertMsg(title: "Error sending member contact invitation", message: "Error: \(responseError(error))")
|
||||
await MainActor.run { composeState.disabled = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if composeState.disabled { composeState.inProgress = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,21 +665,17 @@ struct ComposeView: View {
|
||||
if let oldMsgContent = ei.content.msgContent {
|
||||
do {
|
||||
let mc = updateMsgContent(oldMsgContent)
|
||||
if mc != oldMsgContent || (ei.meta.itemLive ?? false) {
|
||||
let chatItem = try await apiUpdateChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: ei.id,
|
||||
msg: mc,
|
||||
live: live
|
||||
)
|
||||
await MainActor.run {
|
||||
_ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
|
||||
}
|
||||
return chatItem
|
||||
} else {
|
||||
return nil
|
||||
let chatItem = try await apiUpdateChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: ei.id,
|
||||
msg: mc,
|
||||
live: live
|
||||
)
|
||||
await MainActor.run {
|
||||
_ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
|
||||
}
|
||||
return chatItem
|
||||
} catch {
|
||||
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
|
||||
AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))")
|
||||
@@ -744,28 +713,13 @@ struct ComposeView: View {
|
||||
|
||||
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
let (image, data) = imageData
|
||||
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
|
||||
if case let .video(_, url, duration) = data, let savedFile = saveFileFromURLWithoutLoad(url) {
|
||||
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
|
||||
if !privacyEncryptLocalFilesGroupDefault.get() {
|
||||
return CryptoFile.plain(fileName)
|
||||
}
|
||||
let url = getAppFilePath(fileName)
|
||||
let toFile = generateNewFileName("voice", "m4a")
|
||||
let toUrl = getAppFilePath(toFile)
|
||||
if let cfArgs = try? encryptCryptoFile(fromPath: url.path, toPath: toUrl.path) {
|
||||
removeFile(url)
|
||||
return CryptoFile(filePath: toFile, cryptoArgs: cfArgs)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
if let chatItem = await apiSendMessage(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
@@ -782,7 +736,7 @@ struct ComposeView: View {
|
||||
return chatItem
|
||||
}
|
||||
if let file = file {
|
||||
removeFile(file.filePath)
|
||||
removeFile(file)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -802,7 +756,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func saveAnyImage(_ img: UploadContent) -> CryptoFile? {
|
||||
func saveAnyImage(_ img: UploadContent) -> String? {
|
||||
switch img {
|
||||
case let .simpleImage(image): return saveImage(image)
|
||||
case let .animatedImage(image): return saveAnimImage(image)
|
||||
@@ -894,6 +848,7 @@ struct ComposeView: View {
|
||||
|
||||
private func clearState(live: Bool = false) {
|
||||
if live {
|
||||
composeState.disabled = false
|
||||
composeState.inProgress = false
|
||||
} else {
|
||||
composeState = ComposeState()
|
||||
@@ -906,6 +861,12 @@ struct ComposeView: View {
|
||||
}
|
||||
|
||||
private func saveCurrentDraft() {
|
||||
if case .recording = composeState.voiceMessageRecordingState {
|
||||
finishVoiceMessageRecording()
|
||||
if let fileName = composeState.voiceMessageRecordingFileName {
|
||||
chatModel.filesToDelete.insert(getAppFilePath(fileName))
|
||||
}
|
||||
}
|
||||
chatModel.draft = composeState
|
||||
chatModel.draftChatId = chat.id
|
||||
}
|
||||
@@ -982,18 +943,19 @@ struct ComposeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
@State var composeState = ComposeState(message: "hello")
|
||||
@FocusState var keyboardVisible: Bool
|
||||
|
||||
return Group {
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ struct ComposeVoiceView: View {
|
||||
playbackTime = recordingTime // animate progress bar to the end
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileSource: CryptoFile.plain(recordingFileName), at: playbackTime)
|
||||
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
|
||||
playbackState = .playing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
//
|
||||
// ContextInvitingContactMemberView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 18.09.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContextInvitingContactMemberView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "message")
|
||||
.foregroundColor(.secondary)
|
||||
Text("Send direct message to connect")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minHeight: 50)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextInvitingContactMemberView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContextInvitingContactMemberView()
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,13 @@ struct ContextItemView: View {
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundColor(.secondary)
|
||||
if let sender = contextItem.memberDisplayName {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(sender).font(.caption).foregroundColor(.secondary)
|
||||
msgContentView(lines: 2)
|
||||
}
|
||||
} else {
|
||||
msgContentView(lines: 3)
|
||||
}
|
||||
MsgContentView(
|
||||
text: contextItem.text,
|
||||
formattedText: contextItem.formattedText,
|
||||
sender: contextItem.memberDisplayName
|
||||
)
|
||||
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
|
||||
.lineLimit(3)
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation {
|
||||
@@ -45,15 +44,6 @@ struct ContextItemView: View {
|
||||
.background(chatItemFrameColor(contextItem, colorScheme))
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func msgContentView(lines: Int) -> some View {
|
||||
MsgContentView(
|
||||
text: contextItem.text,
|
||||
formattedText: contextItem.formattedText
|
||||
)
|
||||
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
|
||||
.lineLimit(lines)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextItemView_Previews: PreviewProvider {
|
||||
|
||||
@@ -16,7 +16,7 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
@Binding var disableEditing: Bool
|
||||
let height: CGFloat
|
||||
let font: UIFont
|
||||
@Binding var focused: Bool
|
||||
@FocusState.Binding var focused: Bool
|
||||
let alignment: TextAlignment
|
||||
let onImagesAdded: ([UploadContent]) -> Void
|
||||
|
||||
@@ -144,12 +144,13 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
|
||||
struct NativeTextEditor_Previews: PreviewProvider{
|
||||
static var previews: some View {
|
||||
@FocusState var keyboardVisible: Bool
|
||||
return NativeTextEditor(
|
||||
text: Binding.constant("Hello, world!"),
|
||||
disableEditing: Binding.constant(false),
|
||||
height: 100,
|
||||
font: UIFont.preferredFont(forTextStyle: .body),
|
||||
focused: Binding.constant(false),
|
||||
focused: $keyboardVisible,
|
||||
alignment: TextAlignment.leading,
|
||||
onImagesAdded: { _ in }
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@ struct SendMessageView: View {
|
||||
var sendLiveMessage: (() async -> Void)? = nil
|
||||
var updateLiveMessage: (() async -> Void)? = nil
|
||||
var cancelLiveMessage: (() -> Void)? = nil
|
||||
var nextSendGrpInv: Bool = false
|
||||
var showVoiceMessageButton: Bool = true
|
||||
var voiceMessageAllowed: Bool = true
|
||||
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
|
||||
@@ -28,8 +27,7 @@ struct SendMessageView: View {
|
||||
var onMediaAdded: ([UploadContent]) -> Void
|
||||
@State private var holdingVMR = false
|
||||
@Namespace var namespace
|
||||
@Binding var keyboardVisible: Bool
|
||||
var sendButtonColor = Color.accentColor
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
@State private var teHeight: CGFloat = 42
|
||||
@State private var teFont: Font = .body
|
||||
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
|
||||
@@ -38,7 +36,6 @@ struct SendMessageView: View {
|
||||
@State private var showCustomDisappearingMessageDialogue = false
|
||||
@State private var showCustomTimePicker = false
|
||||
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
|
||||
@State private var progressByTimeout = false
|
||||
var maxHeight: CGFloat = 360
|
||||
var minHeight: CGFloat = 37
|
||||
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
@@ -84,7 +81,7 @@ struct SendMessageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if progressByTimeout {
|
||||
if composeState.inProgress {
|
||||
ProgressView()
|
||||
.scaleEffect(1.4)
|
||||
.frame(width: 31, height: 31, alignment: .center)
|
||||
@@ -105,23 +102,12 @@ struct SendMessageView: View {
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
.frame(height: teHeight)
|
||||
}
|
||||
.onChange(of: composeState.inProgress) { inProgress in
|
||||
if inProgress {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
progressByTimeout = composeState.inProgress
|
||||
}
|
||||
} else {
|
||||
progressByTimeout = false
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder private func composeActionButtons() -> some View {
|
||||
let vmrs = composeState.voiceMessageRecordingState
|
||||
if nextSendGrpInv {
|
||||
inviteMemberContactButton()
|
||||
} else if showVoiceMessageButton
|
||||
if showVoiceMessageButton
|
||||
&& composeState.message.isEmpty
|
||||
&& !composeState.editing
|
||||
&& composeState.liveMessage == nil
|
||||
@@ -133,7 +119,7 @@ struct SendMessageView: View {
|
||||
startVoiceMessageRecording: startVoiceMessageRecording,
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
holdingVMR: $holdingVMR,
|
||||
disabled: composeState.inProgress
|
||||
disabled: composeState.disabled
|
||||
)
|
||||
} else {
|
||||
voiceMessageNotAllowedButton()
|
||||
@@ -165,24 +151,6 @@ struct SendMessageView: View {
|
||||
.padding([.top, .trailing], 4)
|
||||
}
|
||||
|
||||
private func inviteMemberContactButton() -> some View {
|
||||
Button {
|
||||
sendMessage(nil)
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(sendButtonColor)
|
||||
.frame(width: sendButtonSize, height: sendButtonSize)
|
||||
.opacity(sendButtonOpacity)
|
||||
}
|
||||
.disabled(
|
||||
!composeState.sendEnabled ||
|
||||
composeState.inProgress
|
||||
)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
private func sendMessageButton() -> some View {
|
||||
Button {
|
||||
sendMessage(nil)
|
||||
@@ -191,13 +159,13 @@ struct SendMessageView: View {
|
||||
? "checkmark.circle.fill"
|
||||
: "arrow.up.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(sendButtonColor)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: sendButtonSize, height: sendButtonSize)
|
||||
.opacity(sendButtonOpacity)
|
||||
}
|
||||
.disabled(
|
||||
!composeState.sendEnabled ||
|
||||
composeState.inProgress ||
|
||||
composeState.disabled ||
|
||||
(!voiceMessageAllowed && composeState.voicePreview) ||
|
||||
composeState.endLiveDisabled
|
||||
)
|
||||
@@ -325,7 +293,7 @@ struct SendMessageView: View {
|
||||
Image(systemName: "mic")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.disabled(composeState.inProgress)
|
||||
.disabled(composeState.disabled)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
@@ -410,7 +378,7 @@ struct SendMessageView: View {
|
||||
Image(systemName: "stop.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(composeState.inProgress)
|
||||
.disabled(composeState.disabled)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
@@ -433,6 +401,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
@State var composeStateNew = ComposeState()
|
||||
let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
@State var composeStateEditing = ComposeState(editingItem: ci)
|
||||
@FocusState var keyboardVisible: Bool
|
||||
@State var sendEnabled: Bool = true
|
||||
|
||||
return Group {
|
||||
@@ -443,7 +412,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
composeState: $composeStateNew,
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
VStack {
|
||||
@@ -453,7 +422,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
composeState: $composeStateEditing,
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: Binding.constant(true)
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
|
||||
|
||||
struct GroupChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@@ -23,8 +21,6 @@ struct GroupChatInfoView: View {
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var connectionCode: String?
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
@@ -34,7 +30,6 @@ struct GroupChatInfoView: View {
|
||||
case clearChatAlert
|
||||
case leaveGroupAlert
|
||||
case cantInviteIncognitoAlert
|
||||
case largeGroupReceiptsDisabled
|
||||
|
||||
var id: GroupChatInfoViewAlert { get { self } }
|
||||
}
|
||||
@@ -57,11 +52,6 @@ struct GroupChatInfoView: View {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
groupPreferencesButton($groupInfo)
|
||||
if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
sendReceiptsOptionDisabled()
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
@@ -125,14 +115,9 @@ struct GroupChatInfoView: View {
|
||||
case .clearChatAlert: return clearChatAlert()
|
||||
case .leaveGroupAlert: return leaveGroupAlert()
|
||||
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
|
||||
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let currentUser = chatModel.currentUser {
|
||||
sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups
|
||||
}
|
||||
sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||
do {
|
||||
if let link = try apiGetGroupLink(groupInfo.groupId) {
|
||||
(groupLink, groupLinkMemberRole) = link
|
||||
@@ -141,7 +126,6 @@ struct GroupChatInfoView: View {
|
||||
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
.keyboardPadding()
|
||||
}
|
||||
|
||||
private func groupInfoHeader() -> some View {
|
||||
@@ -153,14 +137,12 @@ struct GroupChatInfoView: View {
|
||||
.padding()
|
||||
Text(cInfo.displayName)
|
||||
.font(.largeTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
.lineLimit(1)
|
||||
.padding(.bottom, 2)
|
||||
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName {
|
||||
Text(cInfo.fullName)
|
||||
.font(.title2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(8)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -299,9 +281,9 @@ struct GroupChatInfoView: View {
|
||||
do {
|
||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
chatModel.chatId = nil
|
||||
chatModel.removeChat(chat.chatInfo.id)
|
||||
chatModel.chatId = nil
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("deleteGroupAlert apiDeleteChat error: \(error.localizedDescription)")
|
||||
@@ -343,38 +325,6 @@ struct GroupChatInfoView: View {
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func sendReceiptsOption() -> some View {
|
||||
Picker(selection: $sendReceipts) {
|
||||
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
|
||||
Text(opt.text)
|
||||
}
|
||||
} label: {
|
||||
Label("Send receipts", systemImage: "checkmark.message")
|
||||
}
|
||||
.frame(height: 36)
|
||||
.onChange(of: sendReceipts) { _ in
|
||||
setSendReceipts()
|
||||
}
|
||||
}
|
||||
|
||||
private func setSendReceipts() {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.sendRcpts = sendReceipts.bool()
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
private func sendReceiptsOptionDisabled() -> some View {
|
||||
HStack {
|
||||
Label("Send receipts", systemImage: "checkmark.message")
|
||||
Spacer()
|
||||
Text("disabled")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.onTapGesture {
|
||||
alert = .largeGroupReceiptsDisabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
|
||||
@@ -403,13 +353,6 @@ func cantInviteIncognitoAlert() -> Alert {
|
||||
)
|
||||
}
|
||||
|
||||
func largeGroupReceiptsDisabledAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Receipts are disabled"),
|
||||
message: Text("This group has over \(SMALL_GROUPS_RCPS_MEM_LIMIT) members, delivery receipts are not sent.")
|
||||
)
|
||||
}
|
||||
|
||||
struct GroupChatInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData)
|
||||
|
||||
@@ -19,17 +19,14 @@ struct GroupMemberInfoView: View {
|
||||
@State private var connectionCode: String? = nil
|
||||
@State private var newRole: GroupMemberRole = .member
|
||||
@State private var alert: GroupMemberInfoViewAlert?
|
||||
@State private var connectToMemberDialog: Bool = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var justOpened = true
|
||||
@State private var progressIndicator = false
|
||||
|
||||
enum GroupMemberInfoViewAlert: Identifiable {
|
||||
case removeMemberAlert(mem: GroupMember)
|
||||
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case syncConnectionForceAlert
|
||||
case connRequestSentAlert(type: ConnReqType)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case other(alert: Alert)
|
||||
@@ -40,7 +37,6 @@ struct GroupMemberInfoView: View {
|
||||
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||
case .connRequestSentAlert: return "connRequestSentAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
case let .other(alert): return "other \(alert)"
|
||||
@@ -66,173 +62,147 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
|
||||
private func groupMemberInfoView() -> some View {
|
||||
ZStack {
|
||||
VStack {
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
VStack {
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let contactId = member.memberContactId {
|
||||
if let chat = knownDirectChat(contactId) {
|
||||
knownDirectChatButton(chat)
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on {
|
||||
if let contactId = member.memberContactId {
|
||||
newDirectChatButton(contactId)
|
||||
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
||||
createMemberContactButton()
|
||||
}
|
||||
newDirectChatButton(contactId)
|
||||
}
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
QRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [contactLink])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} else {
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
QRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [contactLink])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Member") {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
}
|
||||
|
||||
// TODO invited by - need to get contact by contact id
|
||||
if let conn = member.activeConn {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
}
|
||||
|
||||
if let connStats = connectionStats {
|
||||
Section("Servers") {
|
||||
// TODO network connection status
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
Section {
|
||||
removeMemberButton(member)
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section("For console") {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
|
||||
Section("Member") {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
}
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
member = mem
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
|
||||
// TODO invited by - need to get contact by contact id
|
||||
if let conn = member.activeConn {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
justOpened = false
|
||||
}
|
||||
.onChange(of: newRole) { _ in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
|
||||
if let connStats = connectionStats {
|
||||
Section("Servers") {
|
||||
// TODO network connection status
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil })
|
||||
if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch })
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
Section {
|
||||
removeMemberButton(member)
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section("For console") {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
|
||||
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||
case let .connRequestSentAlert(type): return connReqSentAlert(type)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .other(alert): return alert
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let stats = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
member = mem
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
justOpened = false
|
||||
}
|
||||
.onChange(of: newRole) { _ in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
}
|
||||
}
|
||||
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
|
||||
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||
case let .connRequestSentAlert(type): return connReqSentAlert(type)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .other(alert): return alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaAddressButton(_ contactLink: String) -> some View {
|
||||
Button {
|
||||
connectToMemberDialog = true
|
||||
connectViaAddress(contactLink)
|
||||
} label: {
|
||||
Label("Connect", systemImage: "link")
|
||||
}
|
||||
.confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) {
|
||||
Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) }
|
||||
Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) }
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaAddress(incognito: Bool, contactLink: String) {
|
||||
func connectViaAddress(_ contactLink: String) {
|
||||
Task {
|
||||
let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink)
|
||||
let (connReqType, connectAlert) = await apiConnect_(connReq: contactLink)
|
||||
if let connReqType = connReqType {
|
||||
alert = .connRequestSentAlert(type: connReqType)
|
||||
} else if let connectAlert = connectAlert {
|
||||
@@ -269,63 +239,25 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func createMemberContactButton() -> some View {
|
||||
Button {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, member.groupMemberId)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
dismissAllSheets(animated: true)
|
||||
chatModel.chatId = memberContact.id
|
||||
chatModel.setContactNetworkStatus(memberContact, .connected)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating member contact")
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Send direct message", systemImage: "message")
|
||||
}
|
||||
}
|
||||
|
||||
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
||||
VStack {
|
||||
ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
if mem.verified {
|
||||
(
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
.foregroundColor(.secondary)
|
||||
.font(.title2)
|
||||
+ Text(" ")
|
||||
+ Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.bottom, 2)
|
||||
} else {
|
||||
HStack {
|
||||
if mem.verified {
|
||||
Image(systemName: "checkmark.shield")
|
||||
}
|
||||
Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.bottom, 2)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
if mem.fullName != "" && mem.fullName != mem.displayName {
|
||||
Text(mem.fullName)
|
||||
.font(.title2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -359,24 +291,7 @@ struct GroupMemberInfoView: View {
|
||||
systemImage: member.verified ? "checkmark.shield" : "shield"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
syncMemberConnection(force: false)
|
||||
} label: {
|
||||
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButtonForce() -> some View {
|
||||
Button {
|
||||
alert = .syncConnectionForceAlert
|
||||
} label: {
|
||||
Label("Renegotiate encryption", systemImage: "exclamationmark.triangle")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMemberButton(_ mem: GroupMember) -> some View {
|
||||
@@ -442,11 +357,7 @@ struct GroupMemberInfoView: View {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats)
|
||||
dismiss()
|
||||
}
|
||||
connectionStats = stats
|
||||
} catch let error {
|
||||
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -462,9 +373,6 @@ struct GroupMemberInfoView: View {
|
||||
do {
|
||||
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error aborting address change")
|
||||
@@ -474,25 +382,6 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncMemberConnection(force: Bool) {
|
||||
Task {
|
||||
do {
|
||||
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||
|
||||
@@ -130,7 +130,7 @@ struct GroupProfileView: View {
|
||||
let err = responseError(error)
|
||||
saveGroupError = err
|
||||
showSaveErrorAlert = true
|
||||
logger.error("GroupProfile apiUpdateGroup error: \(err)")
|
||||
logger.error("UserProfile apiUpdateProfile error: \(err)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ struct ScanCodeView: View {
|
||||
VStack(alignment: .leading) {
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
.border(.gray)
|
||||
Text("Scan security code from your contact's app.")
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ struct VerifyCodeView: View {
|
||||
HStack {
|
||||
NavigationLink {
|
||||
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Scan code")
|
||||
} label: {
|
||||
Label("Scan code", systemImage: "qrcode")
|
||||
|
||||
@@ -49,10 +49,11 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
|
||||
NavLinkPlain(
|
||||
let v = NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat) }
|
||||
label: { ChatPreviewView(chat: chat) },
|
||||
disabled: !contact.ready
|
||||
)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
@@ -75,6 +76,14 @@ struct ChatListNavLink: View {
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
|
||||
if contact.ready {
|
||||
v
|
||||
} else {
|
||||
v.onTapGesture {
|
||||
AlertManager.shared.showAlert(pendingContactAlert(chat, contact))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
|
||||
@@ -213,15 +222,9 @@ struct ChatListNavLink: View {
|
||||
ContactRequestView(contactRequest: contactRequest, chat: chat)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
||||
} label: { Label("Accept", systemImage: "checkmark") }
|
||||
.tint(.accentColor)
|
||||
Button {
|
||||
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
|
||||
} label: {
|
||||
Label("Accept incognito", systemImage: "theatermasks")
|
||||
}
|
||||
.tint(.indigo)
|
||||
Task { await acceptContactRequest(contactRequest) }
|
||||
} label: { Label("Accept", systemImage: chatModel.incognito ? "theatermasks" : "checkmark") }
|
||||
.tint(chatModel.incognito ? .indigo : .accentColor)
|
||||
Button {
|
||||
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
|
||||
} label: {
|
||||
@@ -231,10 +234,9 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.onTapGesture { showContactRequestDialog = true }
|
||||
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
|
||||
Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
|
||||
Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
|
||||
.confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||
Button(chatModel.incognito ? "Accept incognito" : "Accept contact") { Task { await acceptContactRequest(contactRequest) } }
|
||||
Button("Reject contact (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +263,6 @@ struct ChatListNavLink: View {
|
||||
.sheet(isPresented: $showContactConnectionInfo) {
|
||||
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
||||
ContactConnectionInfo(contactConnection: contactConnection)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
@@ -378,7 +379,6 @@ struct ChatListNavLink: View {
|
||||
.onTapGesture { showInvalidJSON = true }
|
||||
.sheet(isPresented: $showInvalidJSON) {
|
||||
invalidJSONView(json)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,6 @@ struct ChatListView: View {
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody.scrollDismissesKeyboard(.immediately)
|
||||
} else {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
|
||||
private var viewBody: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NavStackCompat(
|
||||
isActive: Binding(
|
||||
@@ -60,41 +52,15 @@ struct ChatListView: View {
|
||||
chatList
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
||||
.onAppear() { connectViaUrl() }
|
||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
||||
.refreshable {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Reconnect servers?"),
|
||||
message: Text("Reconnect all connected servers to force message delivery. It uses additional traffic."),
|
||||
primaryButton: .default(Text("Ok")) {
|
||||
Task {
|
||||
do {
|
||||
try await reconnectAllServers()
|
||||
} catch let error {
|
||||
AlertManager.shared.showAlertMsg(title: "Error", message: "\(responseError(error))")
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
.offset(x: -8)
|
||||
.listStyle(.plain)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
let user = chatModel.currentUser ?? User.sampleData
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel))
|
||||
.frame(width: 32, height: 32)
|
||||
.padding(.trailing, 4)
|
||||
let allRead = chatModel.users
|
||||
.filter { u in !u.user.activeUser && !u.user.hidden }
|
||||
.allSatisfy { u in u.unreadCount == 0 }
|
||||
if !allRead {
|
||||
unreadBadge(size: 12)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
Button {
|
||||
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
@@ -102,10 +68,28 @@ struct ChatListView: View {
|
||||
} else {
|
||||
showSettings = true
|
||||
}
|
||||
} label: {
|
||||
let user = chatModel.currentUser ?? User.sampleData
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel))
|
||||
.frame(width: 32, height: 32)
|
||||
.padding(.trailing, 4)
|
||||
let allRead = chatModel.users
|
||||
.filter { u in !u.user.activeUser && !u.user.hidden }
|
||||
.allSatisfy { u in u.unreadCount == 0 }
|
||||
if !allRead {
|
||||
unreadBadge(size: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
if (chatModel.incognito) {
|
||||
Image(systemName: "theatermasks")
|
||||
.foregroundColor(.indigo)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Text("Chats")
|
||||
.font(.headline)
|
||||
if chatModel.chats.count > 0 {
|
||||
|
||||
@@ -15,8 +15,6 @@ struct ChatPreviewView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
|
||||
|
||||
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
|
||||
|
||||
var body: some View {
|
||||
let cItem = chat.chatItems.last
|
||||
return HStack(spacing: 8) {
|
||||
@@ -43,9 +41,11 @@ struct ChatPreviewView: View {
|
||||
|
||||
ZStack(alignment: .topTrailing) {
|
||||
chatMessagePreview(cItem)
|
||||
chatStatusImage()
|
||||
.padding(.top, 26)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
if case .direct = chat.chatInfo {
|
||||
chatStatusImage()
|
||||
.padding(.top, 24)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
|
||||
@@ -59,9 +59,12 @@ struct ChatPreviewView: View {
|
||||
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memLeft: groupInactiveIcon()
|
||||
case .memRemoved: groupInactiveIcon()
|
||||
case .memGroupDeleted: groupInactiveIcon()
|
||||
case .memLeft:
|
||||
groupInactiveIcon()
|
||||
case .memRemoved:
|
||||
groupInactiveIcon()
|
||||
case .memGroupDeleted:
|
||||
groupInactiveIcon()
|
||||
default: EmptyView()
|
||||
}
|
||||
} else {
|
||||
@@ -71,7 +74,7 @@ struct ChatPreviewView: View {
|
||||
|
||||
@ViewBuilder private func groupInactiveIcon() -> some View {
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.secondary.opacity(0.65))
|
||||
.foregroundColor(.secondary)
|
||||
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
|
||||
}
|
||||
|
||||
@@ -103,7 +106,7 @@ struct ChatPreviewView: View {
|
||||
.kerning(-2)
|
||||
}
|
||||
|
||||
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
|
||||
private func chatPreviewLayout(_ text: Text) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
text
|
||||
.lineLimit(2)
|
||||
@@ -111,8 +114,6 @@ struct ChatPreviewView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 36)
|
||||
.privacySensitive(!showChatPreviews && !draft)
|
||||
.redacted(reason: .privacy)
|
||||
let s = chat.chatStats
|
||||
if s.unreadCount > 0 || s.unreadChat {
|
||||
unreadCountText(s.unreadCount)
|
||||
@@ -174,18 +175,14 @@ struct ChatPreviewView: View {
|
||||
|
||||
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
|
||||
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
|
||||
chatPreviewLayout(messageDraft(draft), draft: true)
|
||||
chatPreviewLayout(messageDraft(draft))
|
||||
} else if let cItem = cItem {
|
||||
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
|
||||
} else {
|
||||
switch (chat.chatInfo) {
|
||||
case let .direct(contact):
|
||||
if !contact.ready {
|
||||
if contact.nextSendGrpInv {
|
||||
chatPreviewInfoText("send direct message")
|
||||
} else {
|
||||
chatPreviewInfoText("connecting…")
|
||||
}
|
||||
chatPreviewInfoText("connecting…")
|
||||
}
|
||||
case let .group(groupInfo):
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
@@ -201,7 +198,10 @@ struct ChatPreviewView: View {
|
||||
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
|
||||
groupInfo.membership.memberIncognito
|
||||
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
|
||||
: chatPreviewInfoText("you are invited to group")
|
||||
: (chatModel.incognito
|
||||
? chatPreviewInfoText("join as \(chatModel.currentUser?.profile.displayName ?? "yourself")")
|
||||
: chatPreviewInfoText("you are invited to group")
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
|
||||
@@ -229,7 +229,7 @@ struct ChatPreviewView: View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
case .connected: incognitoIcon(chat.chatInfo.incognito)
|
||||
case .connected: EmptyView()
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.resizable()
|
||||
@@ -240,23 +240,11 @@ struct ChatPreviewView: View {
|
||||
ProgressView()
|
||||
}
|
||||
default:
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func incognitoIcon(_ incognito: Bool) -> some View {
|
||||
if incognito {
|
||||
Image(systemName: "theatermasks")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 22, height: 22)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
func unreadCountText(_ n: Int) -> Text {
|
||||
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
|
||||
}
|
||||
@@ -270,20 +258,20 @@ struct ChatPreviewView_Previews: PreviewProvider {
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))]
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)]
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
|
||||
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))]
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now))]
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
|
||||
chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0)
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
|
||||
@@ -15,7 +15,6 @@ struct ContactConnectionInfo: View {
|
||||
@State var contactConnection: PendingContactConnection
|
||||
@State private var alert: CCInfoAlert?
|
||||
@State private var localAlias = ""
|
||||
@State private var showIncognitoSheet = false
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
|
||||
enum CCInfoAlert: Identifiable {
|
||||
@@ -32,14 +31,19 @@ struct ContactConnectionInfo: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
let v = List {
|
||||
List {
|
||||
Group {
|
||||
Text(contactConnection.initiated ? "You invited a contact" : "You accepted connection")
|
||||
Text(contactConnection.initiated ? "You invited your contact" : "You accepted connection")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Text(contactConnectionText(contactConnection))
|
||||
.padding(.bottom, 16)
|
||||
|
||||
if let connReqInv = contactConnection.connReqInv {
|
||||
OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInv)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -61,16 +65,10 @@ struct ContactConnectionInfo: View {
|
||||
|
||||
if contactConnection.initiated,
|
||||
let connReqInv = contactConnection.connReqInv {
|
||||
QRCode(uri: connReqInv)
|
||||
incognitoEnabled()
|
||||
shareLinkButton(connReqInv)
|
||||
oneTimeLinkLearnMoreButton()
|
||||
oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInv)
|
||||
} else {
|
||||
incognitoEnabled()
|
||||
oneTimeLinkLearnMoreButton()
|
||||
}
|
||||
} footer: {
|
||||
sharedProfileInfo(contactConnection.incognito)
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -82,14 +80,6 @@ struct ContactConnectionInfo: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if #available(iOS 16, *) {
|
||||
v
|
||||
} else {
|
||||
// navigationBarHidden is added conditionally,
|
||||
// because the view jumps in iOS 17 if this is added,
|
||||
// and on iOS 16+ it is hidden without it.
|
||||
v.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { _alert in
|
||||
switch _alert {
|
||||
@@ -138,30 +128,6 @@ struct ContactConnectionInfo: View {
|
||||
)
|
||||
: "You will be connected when your contact's device is online, please wait or check later!"
|
||||
}
|
||||
|
||||
@ViewBuilder private func incognitoEnabled() -> some View {
|
||||
if contactConnection.incognito {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: "theatermasks.fill")
|
||||
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
.foregroundColor(Color.indigo)
|
||||
.font(.system(size: 14))
|
||||
HStack(spacing: 6) {
|
||||
Text("Incognito")
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.onTapGesture {
|
||||
showIncognitoSheet = true
|
||||
}
|
||||
.padding(.leading, 36)
|
||||
}
|
||||
.sheet(isPresented: $showIncognitoSheet) {
|
||||
IncognitoHelp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactConnectionInfo_Previews: PreviewProvider {
|
||||
|
||||
@@ -58,14 +58,10 @@ struct ContactConnectionView: View {
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Text(contactConnection.description)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
incognitoIcon(contactConnection.incognito)
|
||||
.padding(.top, 26)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
Text(contactConnection.description)
|
||||
.frame(alignment: .topLeading)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 2)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ struct ContactRequestView: View {
|
||||
Text(contactRequest.chatViewName)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor(chatModel.incognito ? .indigo : .accentColor)
|
||||
.padding(.leading, 8)
|
||||
.frame(alignment: .topLeading)
|
||||
Spacer()
|
||||
|
||||
@@ -65,7 +65,7 @@ struct MigrateToAppGroupView: View {
|
||||
case .exporting:
|
||||
center {
|
||||
ProgressView(value: 0.33)
|
||||
Text("Exporting database archive…")
|
||||
Text("Exporting database archive...")
|
||||
}
|
||||
migrationProgress()
|
||||
case .export_error:
|
||||
@@ -82,7 +82,7 @@ struct MigrateToAppGroupView: View {
|
||||
case .migrating:
|
||||
center {
|
||||
ProgressView(value: 0.67)
|
||||
Text("Migrating database archive…")
|
||||
Text("Migrating database archive...")
|
||||
}
|
||||
migrationProgress()
|
||||
case .migration_error:
|
||||
|
||||
@@ -133,7 +133,7 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable {
|
||||
config.filter = .any(of: [.images, .videos])
|
||||
config.selectionLimit = selectionLimit
|
||||
config.selection = .ordered
|
||||
config.preferredAssetRepresentationMode = .current
|
||||
//config.preferredAssetRepresentationMode = .current
|
||||
let controller = PHPickerViewController(configuration: config)
|
||||
controller.delegate = context.coordinator
|
||||
return controller
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
//
|
||||
// Keyboard.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 10/07/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -1,21 +0,0 @@
|
||||
//
|
||||
// KeyboardPadding.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 10/07/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
@ViewBuilder func keyboardPadding() -> some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
GeometryReader { g in
|
||||
self.padding(.bottom, max(0, ChatModel.shared.keyboardHeight - g.safeAreaInsets.bottom))
|
||||
}
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ struct LocalAuthRequest {
|
||||
}
|
||||
|
||||
func authenticate(title: LocalizedStringKey? = nil, reason: String, selfDestruct: Bool = false, completed: @escaping (LAResult) -> Void) {
|
||||
logger.debug("DEBUGGING: authenticate")
|
||||
logger.debug("authenticate")
|
||||
switch privacyLocalAuthModeDefault.get() {
|
||||
case .system: systemAuthenticate(reason, completed)
|
||||
case .passcode:
|
||||
@@ -58,24 +58,21 @@ func authenticate(title: LocalizedStringKey? = nil, reason: String, selfDestruct
|
||||
}
|
||||
|
||||
func systemAuthenticate(_ reason: String, _ completed: @escaping (LAResult) -> Void) {
|
||||
logger.debug("DEBUGGING: systemAuthenticate")
|
||||
let laContext = LAContext()
|
||||
var authAvailabilityError: NSError?
|
||||
if laContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authAvailabilityError) {
|
||||
logger.debug("DEBUGGING: systemAuthenticate: canEvaluatePolicy callback")
|
||||
laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
|
||||
logger.debug("DEBUGGING: systemAuthenticate evaluatePolicy callback")
|
||||
DispatchQueue.main.async {
|
||||
if success {
|
||||
completed(LAResult.success)
|
||||
} else {
|
||||
logger.error("DEBUGGING: systemAuthenticate authentication error: \(authError.debugDescription)")
|
||||
logger.error("authentication error: \(authError.debugDescription)")
|
||||
completed(LAResult.failed(authError: authError?.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error("DEBUGGING: authentication availability error: \(authAvailabilityError.debugDescription)")
|
||||
logger.error("authentication availability error: \(authAvailabilityError.debugDescription)")
|
||||
completed(LAResult.unavailable(authError: authAvailabilityError?.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,11 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
|
||||
func showShareSheet(items: [Any]) {
|
||||
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
|
||||
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
|
||||
let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController {
|
||||
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
if let completed = completed {
|
||||
let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() }
|
||||
activityViewController.completionWithItemsHandler = handler
|
||||
}
|
||||
presentedViewController.present(activityViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,92 +12,38 @@ import SimpleXChat
|
||||
|
||||
struct AddContactView: View {
|
||||
@EnvironmentObject private var chatModel: ChatModel
|
||||
@Binding var contactConnection: PendingContactConnection?
|
||||
var contactConnection: PendingContactConnection? = nil
|
||||
var connReqInvitation: String
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
Section {
|
||||
if connReqInvitation != "" {
|
||||
QRCode(uri: connReqInvitation)
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
.disabled(contactConnection == nil)
|
||||
shareLinkButton(connReqInvitation)
|
||||
oneTimeLinkLearnMoreButton()
|
||||
} header: {
|
||||
Text("1-time link")
|
||||
} footer: {
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
}
|
||||
List {
|
||||
OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section("1-time link") {
|
||||
oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
|
||||
}
|
||||
}
|
||||
.onAppear { chatModel.connReqInv = connReqInvitation }
|
||||
.onChange(of: incognitoDefault) { incognito in
|
||||
Task {
|
||||
do {
|
||||
if let contactConn = contactConnection,
|
||||
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
|
||||
await MainActor.run {
|
||||
contactConnection = conn
|
||||
ChatModel.shared.updateContactConnection(conn)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IncognitoToggle: View {
|
||||
@Binding var incognitoEnabled: Bool
|
||||
@State private var showIncognitoSheet = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks")
|
||||
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
.foregroundColor(incognitoEnabled ? Color.indigo : .secondary)
|
||||
.font(.system(size: 14))
|
||||
Toggle(isOn: $incognitoEnabled) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Incognito")
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.onTapGesture {
|
||||
showIncognitoSheet = true
|
||||
}
|
||||
}
|
||||
.padding(.leading, 36)
|
||||
}
|
||||
.sheet(isPresented: $showIncognitoSheet) {
|
||||
IncognitoHelp()
|
||||
}
|
||||
@ViewBuilder func oneTimeLinkSection(contactConnection: PendingContactConnection? = nil, connReqInvitation: String) -> some View {
|
||||
if connReqInvitation != "" {
|
||||
QRCode(uri: connReqInvitation)
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
shareLinkButton(connReqInvitation)
|
||||
oneTimeLinkLearnMoreButton()
|
||||
}
|
||||
|
||||
func sharedProfileInfo(_ incognito: Bool) -> Text {
|
||||
let name = ChatModel.shared.currentUser?.displayName ?? ""
|
||||
return Text(
|
||||
incognito
|
||||
? "A new random profile will be shared."
|
||||
: "Your profile **\(name)** will be shared."
|
||||
)
|
||||
}
|
||||
|
||||
func shareLinkButton(_ connReqInvitation: String) -> some View {
|
||||
private func shareLinkButton(_ connReqInvitation: String) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [connReqInvitation])
|
||||
} label: {
|
||||
@@ -119,11 +65,26 @@ func oneTimeLinkLearnMoreButton() -> some View {
|
||||
}
|
||||
}
|
||||
|
||||
struct AddContactView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddContactView(
|
||||
contactConnection: Binding.constant(PendingContactConnection.getSampleData()),
|
||||
connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D"
|
||||
)
|
||||
struct OneTimeLinkProfileText: View {
|
||||
@EnvironmentObject private var chatModel: ChatModel
|
||||
var contactConnection: PendingContactConnection? = nil
|
||||
var connReqInvitation: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if (contactConnection?.incognito ?? chatModel.incognito) {
|
||||
Image(systemName: "theatermasks").foregroundColor(.indigo)
|
||||
Text("A random profile will be sent to your contact")
|
||||
} else {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary)
|
||||
Text("Your chat profile will be sent to your contact")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddContactView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddContactView(connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ struct AddGroupView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createGroupView().keyboardPadding()
|
||||
createGroupView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +47,21 @@ struct AddGroupView: View {
|
||||
.padding(.vertical, 4)
|
||||
Text("The group is fully decentralized – it is visible only to the members.")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Your chat profile will be sent to group members").font(.footnote)
|
||||
if (m.incognito) {
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.orange).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Incognito mode is not supported here - your main profile will be sent to group members").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Your chat profile will be sent to group members").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
ZStack(alignment: .center) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum CreateLinkTab {
|
||||
case oneTime
|
||||
@@ -25,7 +24,6 @@ struct CreateLinkView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State var selection: CreateLinkTab
|
||||
@State var connReqInvitation: String = ""
|
||||
@State var contactConnection: PendingContactConnection? = nil
|
||||
@State private var creatingConnReq = false
|
||||
var viaNavLink = false
|
||||
|
||||
@@ -41,7 +39,7 @@ struct CreateLinkView: View {
|
||||
|
||||
private func createLinkView() -> some View {
|
||||
TabView(selection: $selection) {
|
||||
AddContactView(contactConnection: $contactConnection, connReqInvitation: connReqInvitation)
|
||||
AddContactView(connReqInvitation: connReqInvitation)
|
||||
.tabItem {
|
||||
Label(
|
||||
connReqInvitation == ""
|
||||
@@ -58,7 +56,7 @@ struct CreateLinkView: View {
|
||||
.tag(CreateLinkTab.longTerm)
|
||||
}
|
||||
.onChange(of: selection) { _ in
|
||||
if case .oneTime = selection, connReqInvitation == "", contactConnection == nil && !creatingConnReq {
|
||||
if case .oneTime = selection, connReqInvitation == "" && !creatingConnReq {
|
||||
createInvitation()
|
||||
}
|
||||
}
|
||||
@@ -71,14 +69,12 @@ struct CreateLinkView: View {
|
||||
private func createInvitation() {
|
||||
creatingConnReq = true
|
||||
Task {
|
||||
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
|
||||
await MainActor.run {
|
||||
let connReq = await apiAddContact()
|
||||
await MainActor.run {
|
||||
if let connReq = connReq {
|
||||
connReqInvitation = connReq
|
||||
contactConnection = pcc
|
||||
m.connReqInv = connReq
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
} else {
|
||||
creatingConnReq = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum NewChatAction: Identifiable {
|
||||
case createLink(link: String, connection: PendingContactConnection)
|
||||
case createLink(link: String)
|
||||
case connectViaLink
|
||||
case createGroup
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .createLink(link, _): return "createLink \(link)"
|
||||
case let .createLink(link): return "createLink \(link)"
|
||||
case .connectViaLink: return "connectViaLink"
|
||||
case .createGroup: return "createGroup"
|
||||
}
|
||||
@@ -41,8 +41,8 @@ struct NewChatButton: View {
|
||||
}
|
||||
.sheet(item: $actionSheet) { sheet in
|
||||
switch sheet {
|
||||
case let .createLink(link, pcc):
|
||||
CreateLinkView(selection: .oneTime, connReqInvitation: link, contactConnection: pcc)
|
||||
case let .createLink(link):
|
||||
CreateLinkView(selection: .oneTime, connReqInvitation: link)
|
||||
case .connectViaLink: ConnectViaLinkView()
|
||||
case .createGroup: AddGroupView()
|
||||
}
|
||||
@@ -51,8 +51,8 @@ struct NewChatButton: View {
|
||||
|
||||
func addContactAction() {
|
||||
Task {
|
||||
if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
|
||||
actionSheet = .createLink(link: connReq, connection: pcc)
|
||||
if let connReq = await apiAddContact() {
|
||||
actionSheet = .createLink(link: connReq)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,9 +63,9 @@ enum ConnReqType: Equatable {
|
||||
case invitation
|
||||
}
|
||||
|
||||
func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) {
|
||||
func connectViaLink(_ connectionLink: String, _ dismiss: DismissAction? = nil) {
|
||||
Task {
|
||||
if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
|
||||
if let connReqType = await apiConnect(connReq: connectionLink) {
|
||||
DispatchQueue.main.async {
|
||||
dismiss?()
|
||||
AlertManager.shared.showAlert(connReqSentAlert(connReqType))
|
||||
@@ -100,12 +100,12 @@ func checkCRDataGroup(_ crData: CReqClientData) -> Bool {
|
||||
return crData.type == "group" && crData.groupLinkId != nil
|
||||
}
|
||||
|
||||
func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert {
|
||||
func groupLinkAlert(_ connectionLink: String) -> Alert {
|
||||
return Alert(
|
||||
title: Text("Connect via group link?"),
|
||||
message: Text("You will join a group this link refers to and connect to its group members."),
|
||||
primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) {
|
||||
connectViaLink(connectionLink, incognito: incognito)
|
||||
primaryButton: .default(Text("Connect")) {
|
||||
connectViaLink(connectionLink)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
|
||||
@@ -7,77 +7,76 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct PasteToConnectView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State private var connectionLink: String = ""
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
@FocusState private var linkEditorFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Text("Connect via link")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.onTapGesture { linkEditorFocused = false }
|
||||
|
||||
Section {
|
||||
linkEditor()
|
||||
|
||||
Button {
|
||||
if connectionLink == "" {
|
||||
connectionLink = UIPasteboard.general.string ?? ""
|
||||
} else {
|
||||
connectionLink = ""
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Connect via link")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.vertical)
|
||||
Text("Paste the link you received into the box below to connect with your contact.")
|
||||
.padding(.bottom, 4)
|
||||
if (chatModel.incognito) {
|
||||
HStack {
|
||||
Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("A random profile will be sent to the contact that you received this link from").font(.footnote)
|
||||
}
|
||||
} label: {
|
||||
if connectionLink == "" {
|
||||
settingsRow("doc.plaintext") { Text("Paste") }
|
||||
} else {
|
||||
settingsRow("multiply") { Text("Clear") }
|
||||
.padding(.bottom)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Your profile will be sent to the contact that you received this link from").font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
connect()
|
||||
} label: {
|
||||
settingsRow("link") { Text("Connect") }
|
||||
}
|
||||
.disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
|
||||
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
} footer: {
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
+ Text(String("\n\n"))
|
||||
+ Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func linkEditor() -> some View {
|
||||
ZStack {
|
||||
Group {
|
||||
if connectionLink.isEmpty {
|
||||
TextEditor(text: Binding.constant(NSLocalizedString("Paste the link you received to connect with your contact.", comment: "placeholder")))
|
||||
.foregroundColor(.secondary)
|
||||
.disabled(true)
|
||||
.padding(.bottom)
|
||||
}
|
||||
TextEditor(text: $connectionLink)
|
||||
.onSubmit(connect)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.focused($linkEditorFocused)
|
||||
.allowsTightening(false)
|
||||
.frame(height: 180)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
if connectionLink == "" {
|
||||
Button {
|
||||
connectionLink = UIPasteboard.general.string ?? ""
|
||||
} label: {
|
||||
Label("Paste", systemImage: "doc.plaintext")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
connectionLink = ""
|
||||
} label: {
|
||||
Label("Clear", systemImage: "multiply")
|
||||
}
|
||||
|
||||
}
|
||||
Spacer()
|
||||
Button(action: connect, label: {
|
||||
Label("Connect", systemImage: "link")
|
||||
})
|
||||
.disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
|
||||
}
|
||||
.frame(height: 48)
|
||||
.padding(.bottom)
|
||||
|
||||
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
|
||||
}
|
||||
.allowsTightening(false)
|
||||
.padding(.horizontal, -5)
|
||||
.padding(.top, -8)
|
||||
.frame(height: 180, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +85,9 @@ struct PasteToConnectView: View {
|
||||
if let crData = parseLinkQueryData(link),
|
||||
checkCRDataGroup(crData) {
|
||||
dismiss()
|
||||
AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault))
|
||||
AlertManager.shared.showAlert(groupLinkAlert(link))
|
||||
} else {
|
||||
connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault)
|
||||
connectViaLink(link, dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import CodeScanner
|
||||
|
||||
struct ScanToConnectView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -20,35 +19,34 @@ struct ScanToConnectView: View {
|
||||
Text("Scan QR code")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.vertical)
|
||||
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(uiColor: .systemBackground))
|
||||
)
|
||||
.padding(.top)
|
||||
|
||||
Group {
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
+ Text(String("\n\n"))
|
||||
+ Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
|
||||
if (chatModel.incognito) {
|
||||
HStack {
|
||||
Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("A random profile will be sent to your contact").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Your chat profile will be sent to your contact").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
ZStack {
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.border(.gray)
|
||||
}
|
||||
.padding(.bottom)
|
||||
Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
@@ -57,9 +55,9 @@ struct ScanToConnectView: View {
|
||||
if let crData = parseLinkQueryData(r.string),
|
||||
checkCRDataGroup(crData) {
|
||||
dismiss()
|
||||
AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault))
|
||||
AlertManager.shared.showAlert(groupLinkAlert(r.string))
|
||||
} else {
|
||||
Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) }
|
||||
Task { connectViaLink(r.string, dismiss) }
|
||||
}
|
||||
case let .failure(e):
|
||||
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
|
||||
|
||||
@@ -104,7 +104,6 @@ struct CreateProfile: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.keyboardPadding()
|
||||
}
|
||||
|
||||
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {
|
||||
|
||||
@@ -220,69 +220,7 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.2",
|
||||
post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "checkmark",
|
||||
title: "Message delivery receipts!",
|
||||
description: "The second tick we missed! ✅"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "star",
|
||||
title: "Find chats faster",
|
||||
description: "Filter unread and favorite chats."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "exclamationmark.arrow.triangle.2.circlepath",
|
||||
title: "Keep your connections",
|
||||
description: "Fix encryption after restoring backups."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "stopwatch",
|
||||
title: "Make one message disappear",
|
||||
description: "Even when disabled in the conversation."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "gift",
|
||||
title: "A few more things",
|
||||
description: "- more stable message delivery.\n- a bit better groups.\n- and more!"
|
||||
),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.3",
|
||||
post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "desktopcomputer",
|
||||
title: "New desktop app!",
|
||||
description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "lock",
|
||||
title: "Encrypt stored files & media",
|
||||
description: "App encrypts new local files (except videos)."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "magnifyingglass",
|
||||
title: "Discover and join groups",
|
||||
description: "- connect to [directory service](simplex:/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) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "theatermasks",
|
||||
title: "Simplified incognito mode",
|
||||
description: "Toggle incognito when connecting."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "character",
|
||||
title: "\(4) new interface languages",
|
||||
description: "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
private let lastVersion = versionDescriptions.last!.version
|
||||
@@ -352,15 +290,12 @@ struct WhatsNewView: View {
|
||||
private func featureDescription(_ icon: String, _ title: LocalizedStringKey, _ description: LocalizedStringKey) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(.secondary)
|
||||
Image(systemName: icon).foregroundColor(.secondary)
|
||||
.frame(minWidth: 30, alignment: .center)
|
||||
Text(title).font(.title3).bold()
|
||||
}
|
||||
Text(description)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(10)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,32 +18,14 @@ struct TerminalView: View {
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State var composeState: ComposeState = ComposeState()
|
||||
@State private var keyboardVisible = false
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
@State private var terminalItem: TerminalItem?
|
||||
@State private var scrolled = false
|
||||
@State private var showing = false
|
||||
|
||||
var body: some View {
|
||||
if authorized {
|
||||
terminalView()
|
||||
.onAppear {
|
||||
if showing { return }
|
||||
showing = true
|
||||
Task {
|
||||
let items = await TerminalItems.shared.items()
|
||||
await MainActor.run {
|
||||
chatModel.terminalItems = items
|
||||
chatModel.showingTerminal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if terminalItem == nil {
|
||||
chatModel.showingTerminal = false
|
||||
chatModel.terminalItems = []
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
|
||||
.onAppear(perform: runAuth)
|
||||
@@ -136,8 +118,9 @@ struct TerminalView: View {
|
||||
let cmd = ChatCommand.string(composeState.message)
|
||||
if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) {
|
||||
let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
|
||||
Task {
|
||||
await TerminalItems.shared.addCommand(.now, cmd, resp)
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.addTerminalItem(.cmd(.now, cmd))
|
||||
ChatModel.shared.addTerminalItem(.resp(.now, resp))
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.global().async {
|
||||
|
||||
@@ -51,9 +51,8 @@ struct AdvancedNetworkSettings: View {
|
||||
}
|
||||
.disabled(currentNetCfg == NetCfg.proxyDefaults)
|
||||
|
||||
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel)
|
||||
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
|
||||
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 60_000, 90_000, 120_000], label: secondsLabel)
|
||||
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [2_500000, 5_000000, 7_500000, 10_000000, 15_000000, 20_000000], label: secondsLabel)
|
||||
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [1_500000, 3_000000, 5_000000, 7_000000, 10_000000, 15_000000], label: secondsLabel)
|
||||
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
|
||||
intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "")
|
||||
Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive)
|
||||
@@ -153,9 +152,7 @@ struct AdvancedNetworkSettings: View {
|
||||
|
||||
private func timeoutSettingPicker(_ title: LocalizedStringKey, selection: Binding<Int>, values: [Int], label: String) -> some View {
|
||||
Picker(title, selection: selection) {
|
||||
let v = selection.wrappedValue
|
||||
let vs = values.contains(v) ? values : values + [v]
|
||||
ForEach(vs, id: \.self) { value in
|
||||
ForEach(values, id: \.self) { value in
|
||||
Text("\(String(format: "%g", (Double(value) / 1000000))) \(secondsLabel)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ struct IncognitoHelp: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Text("Incognito mode protects your privacy by using a new random profile for each contact.")
|
||||
Text("Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.")
|
||||
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("To find the profile used for an incognito connection, tap the contact or group name on top of the chat.")
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
@@ -11,12 +11,9 @@ import SimpleXChat
|
||||
|
||||
struct NotificationsView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode
|
||||
@State private var notificationMode: NotificationsMode?
|
||||
@State private var showAlert: NotificationAlert?
|
||||
@State private var legacyDatabase = dbContainerGroupDefault.get() == .documents
|
||||
// @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_LOCAL, store: groupDefaults) private var ntfEnableLocal = false
|
||||
// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_PERIODIC, store: groupDefaults) private var ntfEnablePeriodic = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -29,7 +26,9 @@ struct NotificationsView: View {
|
||||
}
|
||||
} footer: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(ntfModeDescription(notificationMode))
|
||||
if let mode = notificationMode {
|
||||
Text(ntfModeDescription(mode))
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.top, 1)
|
||||
@@ -44,6 +43,7 @@ struct NotificationsView: View {
|
||||
return Alert(title: Text("No device token!"))
|
||||
}
|
||||
}
|
||||
.onAppear { notificationMode = m.notificationMode }
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Send notifications")
|
||||
@@ -76,7 +76,7 @@ struct NotificationsView: View {
|
||||
HStack {
|
||||
Text("Show preview")
|
||||
Spacer()
|
||||
Text(m.notificationPreview.label)
|
||||
Text(m.notificationPreview?.label ?? "")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@@ -88,15 +88,8 @@ struct NotificationsView: View {
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// if developerTools {
|
||||
// Section(String("Experimental")) {
|
||||
// Toggle(String("Always enable local"), isOn: $ntfEnableLocal)
|
||||
// Toggle(String("Always enable periodic"), isOn: $ntfEnablePeriodic)
|
||||
// }
|
||||
// }
|
||||
.disabled(legacyDatabase)
|
||||
}
|
||||
.disabled(legacyDatabase)
|
||||
}
|
||||
|
||||
private func notificationAlert(_ alert: NotificationAlert, _ token: DeviceToken) -> Alert {
|
||||
@@ -173,7 +166,7 @@ func ntfModeDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
|
||||
|
||||
struct SelectionListView<Item: SelectableItem>: View {
|
||||
var list: [Item]
|
||||
@Binding var selection: Item
|
||||
@Binding var selection: Item?
|
||||
var onSelection: ((Item) -> Void)?
|
||||
@State private var tapped: Item? = nil
|
||||
|
||||
|
||||
@@ -71,10 +71,9 @@ struct PreferencesView: View {
|
||||
do {
|
||||
var p = fromLocalProfile(profile)
|
||||
p.preferences = fullPreferencesToPreferences(preferences)
|
||||
if let (newProfile, updatedContacts) = try await apiUpdateProfile(profile: p) {
|
||||
if let newProfile = try await apiUpdateProfile(profile: p) {
|
||||
await MainActor.run {
|
||||
chatModel.updateCurrentUser(newProfile, preferences)
|
||||
updatedContacts.forEach(chatModel.updateContact)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,35 +10,12 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct PrivacySettings: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
|
||||
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
|
||||
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
|
||||
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
|
||||
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@State private var currentLAMode = privacyLocalAuthModeDefault.get()
|
||||
@State private var contactReceipts = false
|
||||
@State private var contactReceiptsReset = false
|
||||
@State private var contactReceiptsOverrides = 0
|
||||
@State private var contactReceiptsDialogue = false
|
||||
@State private var groupReceipts = false
|
||||
@State private var groupReceiptsReset = false
|
||||
@State private var groupReceiptsOverrides = 0
|
||||
@State private var groupReceiptsDialogue = false
|
||||
@State private var alert: PrivacySettingsViewAlert?
|
||||
|
||||
enum PrivacySettingsViewAlert: Identifiable {
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
@@ -64,9 +41,6 @@ struct PrivacySettings: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
settingsRow("lock.doc") {
|
||||
Toggle("Encrypt local files", isOn: $encryptLocalFiles)
|
||||
}
|
||||
settingsRow("photo") {
|
||||
Toggle("Auto-accept images", isOn: $autoAcceptImages)
|
||||
.onChange(of: autoAcceptImages) {
|
||||
@@ -76,18 +50,6 @@ struct PrivacySettings: View {
|
||||
settingsRow("network") {
|
||||
Toggle("Send link previews", isOn: $useLinkPreviews)
|
||||
}
|
||||
settingsRow("message") {
|
||||
Toggle("Show last messages", isOn: $showChatPreviews)
|
||||
}
|
||||
settingsRow("rectangle.and.pencil.and.ellipsis") {
|
||||
Toggle("Message draft", isOn: $saveLastDraft)
|
||||
}
|
||||
.onChange(of: saveLastDraft) { saveDraft in
|
||||
if !saveDraft {
|
||||
m.draft = nil
|
||||
m.draftChatId = nil
|
||||
}
|
||||
}
|
||||
settingsRow("link") {
|
||||
Picker("SimpleX links", selection: $simplexLinkMode) {
|
||||
ForEach(SimpleXLinkMode.values) { mode in
|
||||
@@ -106,175 +68,6 @@ struct PrivacySettings: View {
|
||||
Text("Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
settingsRow("person") {
|
||||
Toggle("Contacts", isOn: $contactReceipts)
|
||||
}
|
||||
settingsRow("person.2") {
|
||||
Toggle("Small groups (max 20)", isOn: $groupReceipts)
|
||||
}
|
||||
} header: {
|
||||
Text("Send delivery receipts to")
|
||||
} footer: {
|
||||
VStack(alignment: .leading) {
|
||||
Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
|
||||
Text("They can be overridden in contact and group settings.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.confirmationDialog(contactReceiptsDialogTitle, isPresented: $contactReceiptsDialogue, titleVisibility: .visible) {
|
||||
Button(contactReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
|
||||
setSendReceiptsContacts(contactReceipts, clearOverrides: false)
|
||||
}
|
||||
Button(contactReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
|
||||
setSendReceiptsContacts(contactReceipts, clearOverrides: true)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
contactReceiptsReset = true
|
||||
contactReceipts.toggle()
|
||||
}
|
||||
}
|
||||
.confirmationDialog(groupReceiptsDialogTitle, isPresented: $groupReceiptsDialogue, titleVisibility: .visible) {
|
||||
Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
|
||||
setSendReceiptsGroups(groupReceipts, clearOverrides: false)
|
||||
}
|
||||
Button(groupReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
|
||||
setSendReceiptsGroups(groupReceipts, clearOverrides: true)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
groupReceiptsReset = true
|
||||
groupReceipts.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: contactReceipts) { _ in
|
||||
if contactReceiptsReset {
|
||||
contactReceiptsReset = false
|
||||
} else {
|
||||
setOrAskSendReceiptsContacts(contactReceipts)
|
||||
}
|
||||
}
|
||||
.onChange(of: groupReceipts) { _ in
|
||||
if groupReceiptsReset {
|
||||
groupReceiptsReset = false
|
||||
} else {
|
||||
setOrAskSendReceiptsGroups(groupReceipts)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let u = m.currentUser {
|
||||
if contactReceipts != u.sendRcptsContacts {
|
||||
contactReceiptsReset = true
|
||||
contactReceipts = u.sendRcptsContacts
|
||||
}
|
||||
if groupReceipts != u.sendRcptsSmallGroups {
|
||||
groupReceiptsReset = true
|
||||
groupReceipts = u.sendRcptsSmallGroups
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { alert in
|
||||
switch alert {
|
||||
case let .error(title, error):
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setOrAskSendReceiptsContacts(_ enable: Bool) {
|
||||
contactReceiptsOverrides = m.chats.reduce(0) { count, chat in
|
||||
let sendRcpts = chat.chatInfo.contact?.chatSettings.sendRcpts
|
||||
return count + (sendRcpts == nil || sendRcpts == enable ? 0 : 1)
|
||||
}
|
||||
if contactReceiptsOverrides == 0 {
|
||||
setSendReceiptsContacts(enable, clearOverrides: false)
|
||||
} else {
|
||||
contactReceiptsDialogue = true
|
||||
}
|
||||
}
|
||||
|
||||
private var contactReceiptsDialogTitle: LocalizedStringKey {
|
||||
contactReceipts
|
||||
? "Sending receipts is disabled for \(contactReceiptsOverrides) contacts"
|
||||
: "Sending receipts is enabled for \(contactReceiptsOverrides) contacts"
|
||||
}
|
||||
|
||||
private func setSendReceiptsContacts(_ enable: Bool, clearOverrides: Bool) {
|
||||
Task {
|
||||
do {
|
||||
if let currentUser = m.currentUser {
|
||||
let userMsgReceiptSettings = UserMsgReceiptSettings(enable: enable, clearOverrides: clearOverrides)
|
||||
try await apiSetUserContactReceipts(currentUser.userId, userMsgReceiptSettings: userMsgReceiptSettings)
|
||||
privacyDeliveryReceiptsSet.set(true)
|
||||
await MainActor.run {
|
||||
var updatedUser = currentUser
|
||||
updatedUser.sendRcptsContacts = enable
|
||||
m.updateUser(updatedUser)
|
||||
if clearOverrides {
|
||||
m.chats.forEach { chat in
|
||||
if var contact = chat.chatInfo.contact {
|
||||
let sendRcpts = contact.chatSettings.sendRcpts
|
||||
if sendRcpts != nil && sendRcpts != enable {
|
||||
contact.chatSettings.sendRcpts = nil
|
||||
m.updateContact(contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
alert = .error(title: "Error setting delivery receipts!", error: "Error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setOrAskSendReceiptsGroups(_ enable: Bool) {
|
||||
groupReceiptsOverrides = m.chats.reduce(0) { count, chat in
|
||||
let sendRcpts = chat.chatInfo.groupInfo?.chatSettings.sendRcpts
|
||||
return count + (sendRcpts == nil || sendRcpts == enable ? 0 : 1)
|
||||
}
|
||||
if groupReceiptsOverrides == 0 {
|
||||
setSendReceiptsGroups(enable, clearOverrides: false)
|
||||
} else {
|
||||
groupReceiptsDialogue = true
|
||||
}
|
||||
}
|
||||
|
||||
private var groupReceiptsDialogTitle: LocalizedStringKey {
|
||||
groupReceipts
|
||||
? "Sending receipts is disabled for \(groupReceiptsOverrides) groups"
|
||||
: "Sending receipts is enabled for \(groupReceiptsOverrides) groups"
|
||||
}
|
||||
|
||||
private func setSendReceiptsGroups(_ enable: Bool, clearOverrides: Bool) {
|
||||
Task {
|
||||
do {
|
||||
if let currentUser = m.currentUser {
|
||||
let userMsgReceiptSettings = UserMsgReceiptSettings(enable: enable, clearOverrides: clearOverrides)
|
||||
try await apiSetUserGroupReceipts(currentUser.userId, userMsgReceiptSettings: userMsgReceiptSettings)
|
||||
privacyDeliveryReceiptsSet.set(true)
|
||||
await MainActor.run {
|
||||
var updatedUser = currentUser
|
||||
updatedUser.sendRcptsSmallGroups = enable
|
||||
m.updateUser(updatedUser)
|
||||
if clearOverrides {
|
||||
m.chats.forEach { chat in
|
||||
if var groupInfo = chat.chatInfo.groupInfo {
|
||||
let sendRcpts = groupInfo.chatSettings.sendRcpts
|
||||
if sendRcpts != nil && sendRcpts != enable {
|
||||
groupInfo.chatSettings.sendRcpts = nil
|
||||
m.updateGroup(groupInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
alert = .error(title: "Error setting delivery receipts!", error: "Error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ struct ScanProtocolServer: View {
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.cornerRadius(12)
|
||||
.padding(.top)
|
||||
ZStack {
|
||||
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.border(.gray)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
//
|
||||
// SetDeliveryReceiptsView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 12/07/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct SetDeliveryReceiptsView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Delivery receipts!")
|
||||
.font(.title)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.vertical)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Enable") {
|
||||
Task {
|
||||
do {
|
||||
if let currentUser = m.currentUser {
|
||||
try await apiSetAllContactReceipts(enable: true)
|
||||
await MainActor.run {
|
||||
var updatedUser = currentUser
|
||||
updatedUser.sendRcptsContacts = true
|
||||
m.updateUser(updatedUser)
|
||||
m.setDeliveryReceipts = false
|
||||
privacyDeliveryReceiptsSet.set(true)
|
||||
}
|
||||
do {
|
||||
let users = try await listUsersAsync()
|
||||
await MainActor.run { m.users = users }
|
||||
} catch let error {
|
||||
logger.debug("listUsers error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Error enabling delivery receipts!"),
|
||||
message: Text("Error: \(responseError(error))")
|
||||
))
|
||||
await MainActor.run {
|
||||
m.setDeliveryReceipts = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.largeTitle)
|
||||
Group {
|
||||
if m.users.count > 1 {
|
||||
Text("Sending delivery receipts will be enabled for all contacts in all visible chat profiles.")
|
||||
} else {
|
||||
Text("Sending delivery receipts will be enabled for all contacts.")
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Delivery receipts are disabled!"),
|
||||
message: Text("You can enable them later via app Privacy & Security settings."),
|
||||
primaryButton: .default(Text("Don't show again")) {
|
||||
m.setDeliveryReceipts = false
|
||||
privacyDeliveryReceiptsSet.set(true)
|
||||
},
|
||||
secondaryButton: .default(Text("Ok")) {
|
||||
m.setDeliveryReceipts = false
|
||||
}
|
||||
))
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Don't enable")
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
Text("You can enable later via Settings").font(.footnote)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
}
|
||||
}
|
||||
|
||||
struct SetDeliveryReceiptsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SetDeliveryReceiptsView()
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,7 @@ let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
|
||||
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
|
||||
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
|
||||
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
|
||||
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
|
||||
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
|
||||
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
|
||||
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
|
||||
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
|
||||
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
|
||||
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
|
||||
@@ -67,10 +64,7 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
|
||||
DEFAULT_PRIVACY_LINK_PREVIEWS: true,
|
||||
DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue,
|
||||
DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true,
|
||||
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
|
||||
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
|
||||
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
|
||||
DEFAULT_EXPERIMENTAL_CALLS: false,
|
||||
DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue,
|
||||
DEFAULT_DEVELOPER_TOOLS: false,
|
||||
@@ -120,8 +114,6 @@ let privacySimplexLinkModeDefault = EnumDefault<SimpleXLinkMode>(defaults: UserD
|
||||
|
||||
let privacyLocalAuthModeDefault = EnumDefault<LAMode>(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system)
|
||||
|
||||
let privacyDeliveryReceiptsSet = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET)
|
||||
|
||||
let onboardingStageDefault = EnumDefault<OnboardingStage>(defaults: UserDefaults.standard, forKey: DEFAULT_ONBOARDING_STAGE, withDefault: .onboardingComplete)
|
||||
|
||||
let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME)
|
||||
@@ -135,6 +127,7 @@ struct SettingsView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||
@Binding var showSettings: Bool
|
||||
@State private var settingsSheet: SettingsSheet?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -164,6 +157,8 @@ struct SettingsView: View {
|
||||
settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") }
|
||||
}
|
||||
|
||||
incognitoRow()
|
||||
|
||||
NavigationLink {
|
||||
UserAddressView(shareViaProfile: chatModel.currentUser!.addressShared)
|
||||
.navigationTitle("SimpleX address")
|
||||
@@ -299,9 +294,38 @@ struct SettingsView: View {
|
||||
}
|
||||
.navigationTitle("Your settings")
|
||||
}
|
||||
.onDisappear {
|
||||
chatModel.showingTerminal = false
|
||||
chatModel.terminalItems = []
|
||||
.sheet(item: $settingsSheet) { sheet in
|
||||
switch sheet {
|
||||
case .incognitoInfo: IncognitoHelp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func incognitoRow() -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: chatModel.incognito ? "theatermasks.fill" : "theatermasks")
|
||||
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
.foregroundColor(chatModel.incognito ? Color.indigo : .secondary)
|
||||
Toggle(isOn: $chatModel.incognito) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Incognito")
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.onTapGesture {
|
||||
settingsSheet = .incognitoInfo
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.incognito) { incognito in
|
||||
incognitoGroupDefault.set(incognito)
|
||||
do {
|
||||
try apiSetIncognito(incognito: incognito)
|
||||
} catch {
|
||||
logger.error("apiSetIncognito: cannot set incognito \(responseError(error))")
|
||||
}
|
||||
}
|
||||
.padding(.leading, indent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +347,12 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private enum SettingsSheet: Identifiable {
|
||||
case incognitoInfo
|
||||
|
||||
var id: SettingsSheet { get { self } }
|
||||
}
|
||||
|
||||
private enum NotificationAlert {
|
||||
case enable
|
||||
case error(LocalizedStringKey, String)
|
||||
|
||||
@@ -190,7 +190,7 @@ struct UserAddressView: View {
|
||||
|
||||
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
|
||||
Section {
|
||||
MutableQRCode(uri: Binding.constant(userAddress.connReqContact))
|
||||
QRCode(uri: userAddress.connReqContact)
|
||||
shareQRCodeButton(userAddress)
|
||||
if MFMailComposeViewController.canSendMail() {
|
||||
shareViaEmailButton(userAddress)
|
||||
|
||||
@@ -144,7 +144,7 @@ struct UserProfile: View {
|
||||
func saveProfile() {
|
||||
Task {
|
||||
do {
|
||||
if let (newProfile, _) = try await apiUpdateProfile(profile: profile) {
|
||||
if let newProfile = try await apiUpdateProfile(profile: profile) {
|
||||
DispatchQueue.main.async {
|
||||
chatModel.updateCurrentUser(newProfile)
|
||||
profile = newProfile
|
||||
|
||||
@@ -3630,51 +3630,6 @@ SimpleX servers cannot see your profile.</source>
|
||||
<source>\~strike~</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ servers" xml:space="preserve" approved="no">
|
||||
<source>%@ servers</source>
|
||||
<target state="translated">%@ الخوادم</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ (current)" xml:space="preserve" approved="no">
|
||||
<source>%@ (current)</source>
|
||||
<target state="translated">%@ (الحالي)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ (current):" xml:space="preserve" approved="no">
|
||||
<source>%@ (current):</source>
|
||||
<target state="translated">%@ (الحالي):</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@:" xml:space="preserve" approved="no">
|
||||
<source>%@:</source>
|
||||
<target state="needs-translation">%@:</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ at %@:" xml:space="preserve" approved="no">
|
||||
<source>%1$@ at %2$@:</source>
|
||||
<target state="translated">%1$@ في %2$@:</target>
|
||||
<note>copied message info, <sender> at <time></note>
|
||||
</trans-unit>
|
||||
<trans-unit id="# %@" xml:space="preserve" approved="no">
|
||||
<source># %@</source>
|
||||
<target state="needs-translation"># %@</target>
|
||||
<note>copied message info title, # <title></note>
|
||||
</trans-unit>
|
||||
<trans-unit id="## History" xml:space="preserve" approved="no">
|
||||
<source>## History</source>
|
||||
<target state="translated">## السجل</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="## In reply to" xml:space="preserve" approved="no">
|
||||
<source>## In reply to</source>
|
||||
<target state="translated">## ردًا على</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ and %@ connected" xml:space="preserve" approved="no">
|
||||
<source>%@ and %@ connected</source>
|
||||
<target state="translated">%@ و %@ متصل</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="ar" datatype="plaintext">
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"locale" : "bg"
|
||||
}
|
||||
],
|
||||
"properties" : {
|
||||
"localizable" : true
|
||||
},
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"red" : "0.000",
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "0.533"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"properties" : {
|
||||
"localizable" : true
|
||||
},
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/* Bundle display name */
|
||||
"CFBundleDisplayName" = "SimpleX NSE";
|
||||
/* Bundle name */
|
||||
"CFBundleName" = "SimpleX NSE";
|
||||
/* Copyright (human-readable) */
|
||||
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
|
||||
@@ -1,30 +0,0 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"`a + b`" = "\\`a + b`";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"~strike~" = "\\~strike~";
|
||||
|
||||
/* call status */
|
||||
"connecting call" = "connecting call…";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connecting server…" = "Connecting to server…";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connecting server… (error: %@)" = "Connecting to server… (error: %@)";
|
||||
|
||||
/* rcv group event chat item */
|
||||
"member connected" = "connected";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/* Bundle name */
|
||||
"CFBundleName" = "SimpleX";
|
||||
/* Privacy - Camera Usage Description */
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"developmentRegion" : "en",
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "bg",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "cs",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "14C18",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "14.2"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "de",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "14C18",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "14.2"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "en",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "14C18",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "14.2"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "es",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "14C18",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "14.2"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"locale" : "fi"
|
||||
}
|
||||
],
|
||||
"properties" : {
|
||||
"localizable" : true
|
||||
},
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user