Compare commits

..

19 Commits

Author SHA1 Message Date
Evgeny Poberezkin 22c62d544c Merge branch 'ep/remove-mtl' into ab/ios-haskell-init 2024-03-31 12:26:52 +01:00
Evgeny Poberezkin fbe9a4da39 strict data, optimization 2024-03-31 11:54:58 +01:00
Alexander Bondarenko 2995a79041 update to deMTLd branches 2024-03-31 13:48:52 +03:00
Alexander Bondarenko da197a35a0 Merge remote-tracking branch 'origin/ep/remove-mtl' into ab/ios-haskell-init 2024-03-31 11:27:14 +03:00
Evgeny Poberezkin 6f8578d0f1 core: remove mtl typeclasses to reduce overhead 2024-03-30 23:49:50 +00:00
Alexander Bondarenko 73cabd3c69 add info tables 2024-03-30 16:35:46 +03:00
Alexander Bondarenko c30c181209 unify init_haskell to allow nse profiling 2024-03-30 15:47:48 +03:00
Alexander Bondarenko 701b4186b6 Merge remote-tracking branch 'origin/master' into ios-profiling 2024-03-30 15:47:29 +03:00
Evgeny Poberezkin 257e03f10a Merge branch 'master' into ios-profiling 2024-03-29 19:44:17 +00:00
Alexander Bondarenko 7b644c0dcf Merge remote-tracking branch 'origin/master' into ios-profiling 2024-03-28 20:10:35 +02:00
Alexander Bondarenko c3d9d9a7c3 Merge remote-tracking branch 'origin/master' into ios-profiling 2024-03-27 17:40:14 +02:00
Alexander Bondarenko 261767035e Merge remote-tracking branch 'origin/master' into ios-profiling 2024-03-20 11:22:48 +02:00
spaced4ndy c9b00b3054 Merge branch 'master' into ios-profiling 2024-03-13 11:50:17 +04:00
spaced4ndy 1697190189 remove print 2024-03-12 20:52:05 +04:00
spaced4ndy 24609a98c6 fix paths 2024-03-12 20:49:42 +04:00
Alexander Bondarenko 60752feb9c fix addr 2024-03-12 17:09:54 +02:00
Alexander Bondarenko ff5ef638cd add missing include 2024-03-12 17:09:54 +02:00
spaced4ndy df619d540b ios: save debug profile, event log buttons (#3899) 2024-03-12 19:06:25 +04:00
Alexander Bondarenko 53d8a85b8c add RTS tuning to haskell_init 2024-03-12 16:58:44 +02:00
848 changed files with 25129 additions and 91361 deletions
+9 -32
View File
@@ -65,11 +65,6 @@ jobs:
asset_name: simplex-chat-ubuntu-22_04-x86-64 asset_name: simplex-chat-ubuntu-22_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
- os: macos-latest - os: macos-latest
ghc: "9.6.3"
cache_path: ~/.cabal/store
asset_name: simplex-chat-macos-aarch64
desktop_asset_name: simplex-desktop-macos-aarch64.dmg
- os: macos-13
ghc: "9.6.3" ghc: "9.6.3"
cache_path: ~/.cabal/store cache_path: ~/.cabal/store
asset_name: simplex-chat-macos-x86-64 asset_name: simplex-chat-macos-x86-64
@@ -112,36 +107,18 @@ jobs:
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
shell: bash shell: bash
run: | run: |
echo "ignore-project: False" >> cabal.project.local echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo "" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Unix prepare cabal.project.local for Mac
if: matrix.os == 'macos-13'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Install AppImage dependencies - name: Install AppImage dependencies
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04' if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
run: sudo apt install -y desktop-file-utils run: sudo apt install -y desktop-file-utils
- name: Install pkg-config for Mac - name: Install pkg-config for Mac
if: matrix.os == 'macos-latest' || matrix.os == 'macos-13' if: matrix.os == 'macos-latest'
run: brew install pkg-config run: brew install pkg-config
- name: Unix prepare cabal.project.local for Ubuntu - name: Unix prepare cabal.project.local for Ubuntu
@@ -213,7 +190,7 @@ jobs:
- name: Mac build desktop - name: Mac build desktop
id: mac_desktop_build id: mac_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
shell: bash shell: bash
env: env:
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }} APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
@@ -264,7 +241,7 @@ jobs:
${{ steps.linux_appimage_build.outputs.appimage_hash }} ${{ steps.linux_appimage_build.outputs.appimage_hash }}
- name: Mac upload desktop package to release - name: Mac upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
@@ -273,7 +250,7 @@ jobs:
tag: ${{ github.ref }} tag: ${{ github.ref }}
- name: Mac update desktop package hash - name: Mac update desktop package hash
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2 -8
View File
@@ -5,20 +5,14 @@ on:
pull_request_target: pull_request_target:
types: [opened, closed, synchronize] types: [opened, closed, synchronize]
permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs: jobs:
CLAssistant: CLAssistant:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "CLA Assistant" - name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request'
# Beta Release # Beta Release
uses: cla-assistant/github-action@v2.3.0 uses: cla-assistant/github-action@v2.1.3-beta
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret # the below token should have repo scope and must be manually added by you in the repository's secret
+1
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- master - master
- stable
paths: paths:
- website/** - website/**
- images/** - images/**
-1
View File
@@ -54,7 +54,6 @@ website/translations.json
website/src/img/images/ website/src/img/images/
website/src/images/ website/src/images/
website/src/js/lottie.min.js website/src/js/lottie.min.js
website/src/privacy.md
# Generated files # Generated files
website/package/generated* website/package/generated*
+10 -17
View File
@@ -1,21 +1,16 @@
---
layout: layouts/privacy.html
permalink: /privacy/index.html
---
# SimpleX Chat Privacy Policy and Conditions of Use # SimpleX Chat Privacy Policy and Conditions of Use
SimpleX Chat is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability. SimpleX Chat is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
SimpleX Chat communication protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX Chat apps allow their users to send messages and files via relay server infrastructure. Relay server owners and providers do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not provide any user accounts. SimpleX Chat communication protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX Chat apps allow their users to send messages and files via relay server infrastructure. Relay server owners and providers do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not provide any user accounts.
Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)). Double ratchet algorithm has such important properties as [forward secrecy](./docs/GLOSSARY.md#forward-secrecy), sender [repudiation](./docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](./docs/GLOSSARY.md#post-compromise-security)).
If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). If you believe that any part of this document is not aligned with our mission or values, please raise it with 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).
## Privacy Policy ## Privacy Policy
SimpleX Chat Ltd uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack), unlike most other communication platforms, services and networks. SimpleX Chat Ltd uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack), unlike most other communication platforms, services and networks.
SimpleX Chat software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having any kind of addresses or other identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications. SimpleX Chat software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having any kind of addresses or other identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
@@ -25,7 +20,7 @@ While SimpleX Chat Ltd is not a communication service provider, and provide publ
We see users and data sovereignty, and device and provider portability as critically important properties for any communication system. We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
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 see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). 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 see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
### Your information ### Your information
@@ -39,7 +34,7 @@ You can transfer the profile to another device by creating a backup of the app d
#### Messages and Files #### Messages and Files
SimpleX relay servers 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 64kb, 256kb, 1mb or 8mb via all or some of the configured file relay 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. SimpleX relay servers 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 64kb, 256kb, 1mb or 8mb via all or some of the configured file relay 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, messaging relay servers temporarily store end-to-end encrypted messages you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too. Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too.
@@ -79,7 +74,7 @@ When you choose to use instant push notifications in SimpleX iOS app, because th
Preset 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 sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers. Preset 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 sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers.
You can read more about the design of iOS push notifications [here](./blog/20220404-simplex-chat-instant-notifications.md#our-ios-approach-has-one-trade-off). 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 #### Another information stored on the servers
@@ -87,7 +82,7 @@ Additional technical information can be stored on our servers, including randoml
#### SimpleX Directory #### SimpleX Directory
[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). [SimpleX Directory](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
#### User Support #### User Support
@@ -108,15 +103,13 @@ The cases when SimpleX Chat Ltd may share the data temporarily stored on the ser
At the time of updating this document, we have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process to limit any information shared with the third parties to the minimally required by law. At the time of updating this document, we have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
We will publish information we are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
### Updates ### 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 software applications and preset relays infrastructure 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 software applications and preset relays infrastructure confirms your acceptance of our updated Privacy Policy.
Please also read our Conditions of Use of Software and Infrastructure below. Please also read our Conditions of Use of Software and Infrastructure below.
If you have questions about our Privacy Policy please contact us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). If you have questions about 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).
## Conditions of Use of Software and Infrastructure ## Conditions of Use of Software and Infrastructure
@@ -138,7 +131,7 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using our Applications, and any associated taxes. **Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using our Applications, and any associated taxes.
**Legal usage**. You agree to use our Applications only for legal purposes. You will not use (or assist others in using) our Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal communications available via our Applications can be shared publicly on social media or websites. We reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via our servers, whether they were reported by the users or discovered by our team. **Legal and acceptable usage**. You agree to use our Applications only for legal and acceptable purposes. You will not use (or assist others in using) our Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal or impermissible communications available via our Applications can be shared publicly on social media or websites. We reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via our servers, whether they were reported by the users or discovered by our team.
**Damage to SimpleX Chat Ltd**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, our Infrastructure, or any other systems. For example, you must not 1) access our Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of our Infrastructure; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software. **Damage to SimpleX Chat Ltd**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, our Infrastructure, or any other systems. For example, you must not 1) access our Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of our Infrastructure; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
@@ -172,4 +165,4 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd. **Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
Updated April 24, 2024 Updated February 24, 2024
+17 -22
View File
@@ -4,7 +4,7 @@
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat) [![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat)
<a rel="me" href="https://mastodon.social/@simplex">![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)</a> <a rel="me" href="https://mastodon.social/@simplex">![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)</a>
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md), [PL](/docs/lang/pl/README.md) | | 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%"> <img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
@@ -18,7 +18,7 @@
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), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend. 3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat). 4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations). 5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
[Learn more about SimpleX Chat](#contents). [Learn more about SimpleX Chat](#contents).
@@ -74,7 +74,7 @@ Messages not following these rules will be deleted, the right to send messages m
You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D) You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform: There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) for developers who build on SimpleX platform:
- chat bots and automations - chat bots and automations
- integrations with other apps - integrations with other apps
@@ -150,7 +150,7 @@ We would love to have you join the development! You can help us with:
- contributing to SimpleX Chat knowledge-base. - contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started. - developing features - please connect to us via chat so we can help you get started.
## Please support us with your donations ## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat! Huge thank you to everybody who donated to SimpleX Chat!
@@ -158,19 +158,20 @@ We are prioritizing users privacy and security - it would be impossible without
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure. Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us. Your donations help us raise more funds any amount, even the price of the cup of coffee, would make a big difference for us.
It is possible to donate via: It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission). - [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad - [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt - Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg - Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- USDT: - USDT:
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu - Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
- please ask if you want to donate any other coins. - Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
Thank you, Thank you,
@@ -233,12 +234,6 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates: Recent and important updates:
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md) [Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md)
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) [Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
@@ -385,10 +380,10 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Private notes. - ✅ Private notes.
- ✅ Improve sending videos (including encryption of locally stored videos). - ✅ Improve sending videos (including encryption of locally stored videos).
- ✅ Post-quantum resistant key exchange in double ratchet protocol. - ✅ Post-quantum resistant key exchange in double ratchet protocol.
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- 🏗 Improve stability and reduce battery usage. - 🏗 Improve stability and reduce battery usage.
- 🏗 Improve experience for the new users. - 🏗 Improve experience for the new users.
- 🏗 Large groups, communities and public channels. - 🏗 Large groups, communities and public channels.
- 🏗 Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- Privacy & security slider - a simple way to set all settings at once. - Privacy & security slider - a simple way to set all settings at once.
- SMP queue redundancy and rotation (manual is supported). - SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address. - Include optional message into connection request sent via contact address.
@@ -417,13 +412,13 @@ We have never provided or have been requested access to our servers or any infor
We do not log IP addresses of the users and we do not perform any traffic correlation on our servers. If transport level security is critical you must use Tor or some other similar network to access messaging servers. We will be improving the client applications to reduce the opportunities for traffic correlation. We do not log IP addresses of the users and we do not perform any traffic correlation on our servers. If transport level security is critical you must use Tor or some other similar network to access messaging servers. We will be improving the client applications to reduce the opportunities for traffic correlation.
Please read more in [Privacy Policy](./PRIVACY.md). Please read more in [Terms & privacy policy](./PRIVACY.md).
## Security contact ## Security contact
Please see our [Security Policy](./docs/SECURITY.md) on how to report security vulnerabilities to us. We will coordinate the fix and disclosure. To report a security vulnerability, please send us email to chat@simplex.chat. We will coordinate the fix and disclosure. Please do NOT report security vulnerabilities via GitHub issues.
Please do NOT report security vulnerabilities via GitHub issues. Please treat any findings of possible traffic correlation attacks allowing to correlate two different conversations to the same user, other than covered in [the threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#threat-model), as security vulnerabilities, and follow this disclosure process.
## License ## License
+21 -74
View File
@@ -9,18 +9,35 @@
import Foundation import Foundation
import UIKit import UIKit
import SimpleXChat import SimpleXChat
import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate { class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
logger.debug("AppDelegate: didFinishLaunchingWithOptions") logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications() application.registerForRemoteNotifications()
if #available(iOS 17.0, *) { trackKeyboard() }
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
removePasscodesIfReinstalled() removePasscodesIfReinstalled()
prepareForLaunch()
return true 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
}
@objc func pasteboardChanged() { @objc func pasteboardChanged() {
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
} }
@@ -124,10 +141,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
} }
} }
private func prepareForLaunch() {
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
}
static func keepScreenOn(_ on: Bool) { static func keepScreenOn(_ on: Bool) {
UIApplication.shared.isIdleTimerDisabled = on UIApplication.shared.isIdleTimerDisabled = on
} }
@@ -135,79 +148,13 @@ class AppDelegate: NSObject, UIApplicationDelegate {
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate { class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
var window: UIWindow? var window: UIWindow?
static var windowStatic: UIWindow?
var windowScene: UIWindowScene? var windowScene: UIWindowScene?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
UITableView.appearance().backgroundColor = .clear
guard let windowScene = scene as? UIWindowScene else { return } guard let windowScene = scene as? UIWindowScene else { return }
self.windowScene = windowScene self.windowScene = windowScene
window = windowScene.keyWindow window = windowScene.keyWindow
SceneDelegate.windowStatic = windowScene.keyWindow window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
migrateAccentColorAndTheme() window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
ThemeManager.applyTheme(currentThemeDefault.get())
ThemeManager.adjustWindowStyle()
}
private func migrateAccentColorAndTheme() {
let defs = UserDefaults.standard
/// For checking migration
// themeOverridesDefault.set([])
// currentThemeDefault.set(DefaultTheme.SYSTEM_THEME_NAME)
// defs.set(0.5, forKey: DEFAULT_ACCENT_COLOR_RED)
// defs.set(0.3, forKey: DEFAULT_ACCENT_COLOR_GREEN)
// defs.set(0.8, forKey: DEFAULT_ACCENT_COLOR_BLUE)
let userInterfaceStyle = getUserInterfaceStyleDefault()
if defs.double(forKey: DEFAULT_ACCENT_COLOR_GREEN) == 0 && userInterfaceStyle == .unspecified {
// No migration needed or already migrated
return
}
let defaultAccentColor = Color(cgColor: CGColor(red: 0.000, green: 0.533, blue: 1.000, alpha: 1))
let accentColor = Color(cgColor: getUIAccentColorDefault())
if accentColor != defaultAccentColor {
let colors = ThemeColors(primary: accentColor.toReadableHex())
var overrides = themeOverridesDefault.get()
var themeIds = currentThemeIdsDefault.get()
switch userInterfaceStyle {
case .light:
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
overrides.append(light)
themeOverridesDefault.set(overrides)
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
currentThemeIdsDefault.set(themeIds)
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
case .dark:
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
overrides.append(dark)
themeOverridesDefault.set(overrides)
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
currentThemeIdsDefault.set(themeIds)
ThemeManager.applyTheme(DefaultTheme.DARK.themeName)
case .unspecified:
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
overrides.append(light)
overrides.append(dark)
themeOverridesDefault.set(overrides)
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
currentThemeIdsDefault.set(themeIds)
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
@unknown default: ()
}
} else if userInterfaceStyle != .unspecified {
let themeName = switch userInterfaceStyle {
case .light: DefaultTheme.LIGHT.themeName
case .dark: DefaultTheme.DARK.themeName
default: DefaultTheme.SYSTEM_THEME_NAME
}
ThemeManager.applyTheme(themeName)
}
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_RED)
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_GREEN)
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_BLUE)
defs.removeObject(forKey: DEFAULT_USER_INTERFACE_STYLE)
} }
} }
@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_cats@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_cats@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_cats@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_flowers@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_flowers@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_flowers@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_hearts@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_hearts@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_hearts@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_kids@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_kids@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_kids@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_school@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_school@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_school@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "wallpaper_travel@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "wallpaper_travel@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "wallpaper_travel@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

+1 -24
View File
@@ -14,8 +14,6 @@ struct ContentView: View {
@ObservedObject var alertManager = AlertManager.shared @ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared @ObservedObject var callController = CallController.shared
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var sceneDelegate: SceneDelegate
var contentAccessAuthenticationExtended: Bool var contentAccessAuthenticationExtended: Bool
@@ -53,16 +51,6 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
if #available(iOS 16.0, *) {
allViews()
.scrollContentBackground(.hidden)
} else {
// on iOS 15 scroll view background disabled in SceneDelegate
allViews()
}
}
@ViewBuilder func allViews() -> some View {
ZStack { ZStack {
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings. // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
@@ -150,17 +138,6 @@ struct ContentView: View {
break break
} }
} }
.onAppear {
reactOnDarkThemeChanges()
}
.onChange(of: colorScheme) { scheme in
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
// so when a user changes his global theme from dark to light or back, the app will adapt to it
reactOnDarkThemeChanges()
}
.onChange(of: theme.name) { _ in
ThemeManager.adjustWindowStyle()
}
} }
@ViewBuilder private func contentView() -> some View { @ViewBuilder private func contentView() -> some View {
@@ -247,7 +224,7 @@ struct ContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity ) .frame(maxWidth: .infinity, maxHeight: .infinity )
.background( .background(
Rectangle() Rectangle()
.fill(theme.colors.background) .fill(.background)
) )
} }
+1 -1
View File
@@ -179,7 +179,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
if playback { if playback {
if AVAudioSession.sharedInstance().category != .playback { if AVAudioSession.sharedInstance().category != .playback {
logger.log("AudioSession: playback") logger.log("AudioSession: playback")
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: [.duckOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: .duckOthers)
} }
} else { } else {
if AVAudioSession.sharedInstance().category != .soloAmbient { if AVAudioSession.sharedInstance().category != .soloAmbient {
+91 -311
View File
@@ -43,95 +43,11 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
items.append(item) items.append(item)
} }
class ItemsModel: ObservableObject {
static let shared = ItemsModel()
private let publisher = ObservableObjectPublisher()
private var bag = Set<AnyCancellable>()
var reversedChatItems: [ChatItem] = [] {
willSet { publisher.send() }
}
var itemAdded = false {
willSet { publisher.send() }
}
// Publishes directly to `objectWillChange` publisher,
// this will cause reversedChatItems to be rendered without throttling
@Published var isLoading = false
@Published var showLoadingProgress = false
init() {
publisher
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
let navigationTimeout = Task {
do {
try await Task.sleep(nanoseconds: 250_000000)
await MainActor.run {
willNavigate()
ChatModel.shared.chatId = chatId
}
} catch {}
}
let progressTimeout = Task {
do {
try await Task.sleep(nanoseconds: 1500_000000)
await MainActor.run { showLoadingProgress = true }
} catch {}
}
Task {
if let chat = ChatModel.shared.getChat(chatId) {
await MainActor.run { self.isLoading = true }
// try? await Task.sleep(nanoseconds: 5000_000000)
await loadChat(chat: chat)
navigationTimeout.cancel()
progressTimeout.cancel()
await MainActor.run {
self.isLoading = false
self.showLoadingProgress = false
willNavigate()
ChatModel.shared.chatId = chatId
}
}
}
}
}
class NetworkModel: ObservableObject {
// map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
static let shared = NetworkModel()
private init() { }
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] = status
}
}
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] ?? .unknown
} else {
.unknown
}
}
}
final class ChatModel: ObservableObject { final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage? @Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false @Published var setDeliveryReceipts = false
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
@Published var currentUser: User? { @Published var currentUser: User?
didSet {
ThemeManager.applyTheme(currentThemeDefault.get())
}
}
@Published var users: [UserInfo] = [] @Published var users: [UserInfo] = []
@Published var chatInitialized = false @Published var chatInitialized = false
@Published var chatRunning: Bool? @Published var chatRunning: Bool?
@@ -145,13 +61,14 @@ final class ChatModel: ObservableObject {
// list of chat "previews" // list of chat "previews"
@Published var chats: [Chat] = [] @Published var chats: [Chat] = []
@Published var deletedChats: Set<String> = [] @Published var deletedChats: Set<String> = []
// map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
// current chat // current chat
@Published var chatId: String? @Published var chatId: String?
@Published var reversedChatItems: [ChatItem] = []
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:] var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String? @Published var chatToTop: String?
@Published var groupMembers: [GMember] = [] @Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
@Published var membersLoaded = false
// items in the terminal view // items in the terminal view
@Published var showingTerminal = false @Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = [] @Published var terminalItems: [TerminalItem] = []
@@ -183,8 +100,9 @@ final class ChatModel: ObservableObject {
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source @Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
@Published var draft: ComposeState? @Published var draft: ComposeState?
@Published var draftChatId: String? @Published var draftChatId: String?
// tracks keyboard height via subscription in AppDelegate
@Published var keyboardHeight: CGFloat = 0
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings @Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
var messageDelivery: Dictionary<Int64, () -> Void> = [:] var messageDelivery: Dictionary<Int64, () -> Void> = [:]
@@ -192,8 +110,6 @@ final class ChatModel: ObservableObject {
static let shared = ChatModel() static let shared = ChatModel()
let im = ItemsModel.shared
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true let ntfEnableLocal = true
@@ -259,47 +175,18 @@ final class ChatModel: ObservableObject {
} }
} }
func populateGroupMembersIndexes() {
groupMembersIndexes.removeAll()
for (i, member) in groupMembers.enumerated() {
groupMembersIndexes[member.groupMemberId] = i
}
}
func getGroupMember(_ groupMemberId: Int64) -> GMember? { func getGroupMember(_ groupMemberId: Int64) -> GMember? {
if let i = groupMembersIndexes[groupMemberId] { groupMembers.first { $0.groupMemberId == groupMemberId }
return groupMembers[i]
}
return nil
}
func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
if chatId == groupInfo.id {
self.groupMembers = groupMembers.map { GMember.init($0) }
self.populateGroupMembersIndexes()
self.membersLoaded = true
updateView()
}
}
} }
private func getChatIndex(_ id: String) -> Int? { private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id }) chats.firstIndex(where: { $0.id == id })
} }
func addChat(_ chat: Chat) { func addChat(_ chat: Chat, at position: Int = 0) {
if chatId == nil { withAnimation {
withAnimation { addChat_(chat, at: 0) } chats.insert(chat, at: position)
} else {
addChat_(chat, at: 0)
} }
popChatCollector.throttlePopChat(chat.chatInfo.id, currentPosition: 0)
}
func addChat_(_ chat: Chat, at position: Int = 0) {
chats.insert(chat, at: position)
} }
func updateChatInfo(_ cInfo: ChatInfo) { func updateChatInfo(_ cInfo: ChatInfo) {
@@ -373,11 +260,10 @@ final class ChatModel: ObservableObject {
} }
} }
} else { } else {
addChat_(Chat(c), at: i) addChat(Chat(c), at: i)
} }
} }
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers()) NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
popChatCollector.clear()
} }
// func addGroup(_ group: SimpleXChat.Group) { // func addGroup(_ group: SimpleXChat.Group) {
@@ -385,12 +271,6 @@ final class ChatModel: ObservableObject {
// } // }
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// mark chat non deleted
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
updateContact(updatedContact)
}
// update previews // update previews
if let i = getChatIndex(cInfo.id) { if let i = getChatIndex(cInfo.id) {
chats[i].chatItems = switch cInfo { chats[i].chatItems = switch cInfo {
@@ -408,9 +288,18 @@ final class ChatModel: ObservableObject {
[cItem] [cItem]
} }
if case .rcvNew = cItem.meta.itemStatus { if case .rcvNew = cItem.meta.itemStatus {
unreadCollector.changeUnreadCounter(cInfo.id, by: 1) chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
increaseUnreadCounter(user: currentUser!)
}
if i > 0 {
if chatId == nil {
withAnimation { popChat_(i) }
} else if chatId == cInfo.id {
chatToTop = cInfo.id
} else {
popChat_(i)
}
} }
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
} else { } else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
} }
@@ -425,7 +314,7 @@ final class ChatModel: ObservableObject {
var res: Bool var res: Bool
if let chat = getChat(cInfo.id) { if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last { if let pItem = chat.chatItems.last {
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
chat.chatItems = [cItem] chat.chatItems = [cItem]
} }
} else { } else {
@@ -436,27 +325,23 @@ final class ChatModel: ObservableObject {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
res = true res = true
} }
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
// update current chat // update current chat
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
} }
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) { if let i = getChatItemIndex(cItem) {
withConditionalAnimation { withAnimation {
_updateChatItem(at: i, with: cItem) _updateChatItem(at: i, with: cItem)
} }
return false return false
} else { } else {
withConditionalAnimation(itemAnimation()) { withAnimation(itemAnimation()) {
var ci = cItem var ci = cItem
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status ci.meta.itemStatus = status
} }
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
} }
return true return true
} }
@@ -471,7 +356,7 @@ final class ChatModel: ObservableObject {
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) { func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) { if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
withConditionalAnimation { withAnimation {
_updateChatItem(at: i, with: cItem) _updateChatItem(at: i, with: cItem)
} }
} else if let status = status { } else if let status = status {
@@ -480,17 +365,17 @@ final class ChatModel: ObservableObject {
} }
private func _updateChatItem(at i: Int, with cItem: ChatItem) { private func _updateChatItem(at i: Int, with cItem: ChatItem) {
im.reversedChatItems[i] = cItem reversedChatItems[i] = cItem
im.reversedChatItems[i].viewTimestamp = .now reversedChatItems[i].viewTimestamp = .now
} }
func getChatItemIndex(_ cItem: ChatItem) -> Int? { func getChatItemIndex(_ cItem: ChatItem) -> Int? {
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id }) reversedChatItems.firstIndex(where: { $0.id == cItem.id })
} }
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew { if cItem.isRcvNew {
unreadCollector.changeUnreadCounter(cInfo.id, by: -1) decreaseUnreadCounter(cInfo)
} }
// update previews // update previews
if let chat = getChat(cInfo.id) { if let chat = getChat(cInfo.id) {
@@ -502,24 +387,23 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id { if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) { if let i = getChatItemIndex(cItem) {
_ = withAnimation { _ = withAnimation {
im.reversedChatItems.remove(at: i) self.reversedChatItems.remove(at: i)
} }
} }
} }
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
} }
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? { func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil } guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous { if previous {
while i < im.reversedChatItems.count - 1 { while i < reversedChatItems.count - 1 {
i += 1 i += 1
if let res = map(im.reversedChatItems[i]) { return res } if let res = map(reversedChatItems[i]) { return res }
} }
} else { } else {
while i > 0 { while i > 0 {
i -= 1 i -= 1
if let res = map(im.reversedChatItems[i]) { return res } if let res = map(reversedChatItems[i]) { return res }
} }
} }
return nil return nil
@@ -537,21 +421,10 @@ final class ChatModel: ObservableObject {
} }
} }
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
guard var current = currentUser else { return }
current.uiThemes = uiThemes
let i = users.firstIndex(where: { $0.user.userId == current.userId })
if let i {
users[i].user = current
}
currentUser = current
}
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem { func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
let cItem = ChatItem.liveDummy(chatInfo.chatType) let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation { withAnimation {
im.reversedChatItems.insert(cItem, at: 0) reversedChatItems.insert(cItem, at: 0)
im.itemAdded = true
} }
return cItem return cItem
} }
@@ -559,15 +432,15 @@ final class ChatModel: ObservableObject {
func removeLiveDummy(animated: Bool = true) { func removeLiveDummy(animated: Bool = true) {
if hasLiveDummy { if hasLiveDummy {
if animated { if animated {
withAnimation { _ = im.reversedChatItems.removeFirst() } withAnimation { _ = reversedChatItems.removeFirst() }
} else { } else {
_ = im.reversedChatItems.removeFirst() _ = reversedChatItems.removeFirst()
} }
} }
} }
private var hasLiveDummy: Bool { private var hasLiveDummy: Bool {
im.reversedChatItems.first?.isLiveDummy == true reversedChatItems.first?.isLiveDummy == true
} }
func markChatItemsRead(_ cInfo: ChatInfo) { func markChatItemsRead(_ cInfo: ChatInfo) {
@@ -584,7 +457,7 @@ final class ChatModel: ObservableObject {
private func markCurrentChatRead(fromIndex i: Int = 0) { private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i var j = i
while j < im.reversedChatItems.count { while j < reversedChatItems.count {
markChatItemRead_(j) markChatItemRead_(j)
j += 1 j += 1
} }
@@ -598,7 +471,7 @@ final class ChatModel: ObservableObject {
var unreadBelow = 0 var unreadBelow = 0
var j = i - 1 var j = i - 1
while j >= 0 { while j >= 0 {
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus { if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
unreadBelow += 1 unreadBelow += 1
} }
j -= 1 j -= 1
@@ -633,146 +506,51 @@ final class ChatModel: ObservableObject {
// clear current chat // clear current chat
if chatId == cInfo.id { if chatId == cInfo.id {
chatItemStatuses = [:] chatItemStatuses = [:]
im.reversedChatItems = [] reversedChatItems = []
} }
} }
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if chatId == cInfo.id, // update preview
let itemIndex = getChatItemIndex(cItem), decreaseUnreadCounter(cInfo)
im.reversedChatItems[itemIndex].isRcvNew { // update current chat
await MainActor.run { if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
withTransaction(Transaction()) { markChatItemRead_(i)
// update current chat
markChatItemRead_(itemIndex)
// update preview
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
}
}
}
}
private let unreadCollector = UnreadCollector()
class UnreadCollector {
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var unreadCounts: [ChatId: Int] = [:]
init() {
subject
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink {
let m = ChatModel.shared
for (chatId, count) in self.unreadCounts {
if let i = m.getChatIndex(chatId) {
m.changeUnreadCounter(i, by: count)
}
}
self.unreadCounts = [:]
}
.store(in: &bag)
}
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
DispatchQueue.main.async {
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
}
subject.send()
}
}
let popChatCollector = PopChatCollector()
class PopChatCollector {
private let subject = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>()
private var chatsToPop: [ChatId: Date] = [:]
private let popTsComparator = KeyPathComparator<Chat>(\.popTs, order: .reverse)
init() {
subject
.throttle(for: 2, scheduler: DispatchQueue.main, latest: true)
.sink { self.popCollectedChats() }
.store(in: &bag)
}
func throttlePopChat(_ chatId: ChatId, currentPosition: Int) {
let m = ChatModel.shared
if currentPosition > 0 && m.chatId == chatId {
m.chatToTop = chatId
}
if currentPosition > 0 || !chatsToPop.isEmpty {
chatsToPop[chatId] = Date.now
subject.send()
}
}
func clear() {
chatsToPop = [:]
}
func popCollectedChats() {
let m = ChatModel.shared
var ixs: IndexSet = []
var chs: [Chat] = []
// collect chats that received updates
for (chatId, popTs) in self.chatsToPop {
// Currently opened chat is excluded, removing it from the list would navigate out of it
// It will be popped to top later when user exits from the list.
if m.chatId != chatId, let i = m.getChatIndex(chatId) {
ixs.insert(i)
let ch = m.chats[i]
ch.popTs = popTs
chs.append(ch)
}
}
let removeInsert = {
m.chats.remove(atOffsets: ixs)
// sort chats by pop timestamp in descending order
m.chats.insert(contentsOf: chs.sorted(using: self.popTsComparator), at: 0)
}
if m.chatId == nil {
withAnimation { removeInsert() }
} else {
removeInsert()
}
self.chatsToPop = [:]
} }
} }
private func markChatItemRead_(_ i: Int) { private func markChatItemRead_(_ i: Int) {
let meta = im.reversedChatItems[i].meta let meta = reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus { if case .rcvNew = meta.itemStatus {
im.reversedChatItems[i].meta.itemStatus = .rcvRead reversedChatItems[i].meta.itemStatus = .rcvRead
im.reversedChatItems[i].viewTimestamp = .now reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl { if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl) reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
} }
} }
} }
func changeUnreadCounter(_ chatIndex: Int, by count: Int) { func decreaseUnreadCounter(_ cInfo: ChatInfo) {
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count if let i = getChatIndex(cInfo.id) {
changeUnreadCounter(user: currentUser!, by: count) chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
decreaseUnreadCounter(user: currentUser!)
}
} }
func increaseUnreadCounter(user: any UserLike) { func increaseUnreadCounter(user: any UserLike) {
changeUnreadCounter(user: user, by: 1) changeUnreadCounter(user: user, by: 1)
NtfManager.shared.incNtfBadgeCount()
} }
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) { func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
changeUnreadCounter(user: user, by: -by) changeUnreadCounter(user: user, by: -by)
NtfManager.shared.decNtfBadgeCount(by: by)
} }
private func changeUnreadCounter(user: any UserLike, by: Int) { private func changeUnreadCounter(user: any UserLike, by: Int) {
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) { if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
users[i].unreadCount += by users[i].unreadCount += by
} }
NtfManager.shared.changeNtfBadgeCount(by: by)
} }
func totalUnreadCountForAllUsers() -> Int { func totalUnreadCountForAllUsers() -> Int {
@@ -786,8 +564,8 @@ final class ChatModel: ObservableObject {
var ns: [String] = [] var ns: [String] = []
if let ciCategory = chatItem.mergeCategory, if let ciCategory = chatItem.mergeCategory,
var i = getChatItemIndex(chatItem) { var i = getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count { while i < reversedChatItems.count {
let ci = im.reversedChatItems[i] let ci = reversedChatItems[i]
if ci.mergeCategory != ciCategory { break } if ci.mergeCategory != ciCategory { break }
if let m = ci.memberConnected { if let m = ci.memberConnected {
ns.append(m.displayName) ns.append(m.displayName)
@@ -802,7 +580,7 @@ final class ChatModel: ObservableObject {
// returns the index of the passed item and the next item (it has smaller index) // returns the index of the passed item and the next item (it has smaller index)
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) { func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
if let i = getChatItemIndex(ci) { if let i = getChatItemIndex(ci) {
(i, i > 0 ? im.reversedChatItems[i - 1] : nil) (i, i > 0 ? reversedChatItems[i - 1] : nil)
} else { } else {
(nil, nil) (nil, nil)
} }
@@ -812,10 +590,10 @@ final class ChatModel: ObservableObject {
// and the previous visible item with another merge category // and the previous visible item with another merge category
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) { func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
guard var i = ciIndex else { return (nil, nil) } guard var i = ciIndex else { return (nil, nil) }
let fst = im.reversedChatItems.count - 1 let fst = reversedChatItems.count - 1
while i < fst { while i < fst {
i = i + 1 i = i + 1
let ci = im.reversedChatItems[i] let ci = reversedChatItems[i]
if ciCategory == nil || ciCategory != ci.mergeCategory { if ciCategory == nil || ciCategory != ci.mergeCategory {
return (i - 1, ci) return (i - 1, ci)
} }
@@ -828,7 +606,7 @@ final class ChatModel: ObservableObject {
var prevMember: GroupMember? = nil var prevMember: GroupMember? = nil
var memberIds: Set<Int64> = [] var memberIds: Set<Int64> = []
for i in range { for i in range {
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir { if case let .groupRcv(m) = reversedChatItems[i].chatDir {
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
memberIds.insert(m.groupMemberId) memberIds.insert(m.groupMemberId)
} }
@@ -838,7 +616,6 @@ final class ChatModel: ObservableObject {
func popChat(_ id: String) { func popChat(_ id: String) {
if let i = getChatIndex(id) { if let i = getChatIndex(id) {
// no animation here, for it not to look like it just moved when leaving the chat
popChat_(i) popChat_(i)
} }
} }
@@ -873,17 +650,14 @@ final class ChatModel: ObservableObject {
} }
// update current chat // update current chat
if chatId == groupInfo.id { if chatId == groupInfo.id {
if let i = groupMembersIndexes[member.groupMemberId] { if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
withAnimation(.default) { withAnimation(.default) {
self.groupMembers[i].wrapped = member self.groupMembers[i].wrapped = member
self.groupMembers[i].created = Date.now self.groupMembers[i].created = Date.now
} }
return false return false
} else { } else {
withAnimation { withAnimation { groupMembers.append(GMember(member)) }
groupMembers.append(GMember(member))
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
}
return true return true
} }
} else { } else {
@@ -904,29 +678,37 @@ final class ChatModel: ObservableObject {
var i = 0 var i = 0
var totalBelow = 0 var totalBelow = 0
var unreadBelow = 0 var unreadBelow = 0
while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) { while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
totalBelow += 1 totalBelow += 1
if im.reversedChatItems[i].isRcvNew { if reversedChatItems[i].isRcvNew {
unreadBelow += 1 unreadBelow += 1
} }
i += 1 i += 1
} }
return UnreadChatItemCounts( return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
// TODO these thresholds account for the fact that items are still "visible" while
// covered by compose area, they should be replaced with the actual height in pixels below the screen.
isNearBottom: totalBelow < 15,
isReallyNearBottom: totalBelow < 2,
unreadBelow: unreadBelow
)
} }
func topItemInView(itemsInView: Set<String>) -> ChatItem? { func topItemInView(itemsInView: Set<String>) -> ChatItem? {
let maxIx = im.reversedChatItems.count - 1 let maxIx = reversedChatItems.count - 1
var i = 0 var i = 0
let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) } let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
while i < maxIx && !inView(i) { i += 1 } while i < maxIx && !inView(i) { i += 1 }
while i < maxIx && inView(i) { i += 1 } while i < maxIx && inView(i) { i += 1 }
return im.reversedChatItems[min(i - 1, maxIx)] return reversedChatItems[min(i - 1, maxIx)]
}
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] = status
}
}
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
if let conn = contact.activeConn {
networkStatuses[conn.agentConnId] ?? .unknown
} else {
.unknown
}
} }
} }
@@ -940,18 +722,16 @@ struct NTFContactRequest {
var chatId: String var chatId: String
} }
struct UnreadChatItemCounts: Equatable { struct UnreadChatItemCounts {
var isNearBottom: Bool var totalBelow: Int
var isReallyNearBottom: Bool
var unreadBelow: Int var unreadBelow: Int
} }
final class Chat: ObservableObject, Identifiable, ChatLike { final class Chat: ObservableObject, Identifiable {
@Published var chatInfo: ChatInfo @Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem] @Published var chatItems: [ChatItem]
@Published var chatStats: ChatStats @Published var chatStats: ChatStats
var created = Date.now var created = Date.now
fileprivate var popTs: Date?
init(_ cData: ChatData) { init(_ cData: ChatData) {
self.chatInfo = cData.chatInfo self.chatInfo = cData.chatInfo
@@ -7,19 +7,18 @@
// //
import Foundation import Foundation
import SimpleXChat
import SwiftUI import SwiftUI
import AVKit import AVKit
import SwiftyGif
import LinkPresentation
public func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? { func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
if let file = file, file.loaded { if let file = file, file.loaded {
return file.fileSource return file.fileSource
} }
return nil return nil
} }
public func getLoadedImage(_ file: CIFile?) -> UIImage? { func getLoadedImage(_ file: CIFile?) -> UIImage? {
if let fileSource = getLoadedFileSource(file) { if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath) let filePath = getAppFilePath(fileSource.filePath)
do { do {
@@ -38,7 +37,7 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? {
return nil return nil
} }
public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data { func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
if let cfArgs = cfArgs { if let cfArgs = cfArgs {
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs) return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
} else { } else {
@@ -46,7 +45,7 @@ public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
} }
} }
public func getLoadedVideo(_ file: CIFile?) -> URL? { func getLoadedVideo(_ file: CIFile?) -> URL? {
if let fileSource = getLoadedFileSource(file) { if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath) let filePath = getAppFilePath(fileSource.filePath)
if FileManager.default.fileExists(atPath: filePath.path) { if FileManager.default.fileExists(atPath: filePath.path) {
@@ -56,13 +55,13 @@ public func getLoadedVideo(_ file: CIFile?) -> URL? {
return nil return nil
} }
public func saveAnimImage(_ image: UIImage) -> CryptoFile? { func saveAnimImage(_ image: UIImage) -> CryptoFile? {
let fileName = generateNewFileName("IMG", "gif") let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil } guard let imageData = image.imageData else { return nil }
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get()) return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
} }
public func saveImage(_ uiImage: UIImage) -> CryptoFile? { func saveImage(_ uiImage: UIImage) -> CryptoFile? {
let hasAlpha = imageHasAlpha(uiImage) let hasAlpha = imageHasAlpha(uiImage)
let ext = hasAlpha ? "png" : "jpg" let ext = hasAlpha ? "png" : "jpg"
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) { if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
@@ -72,7 +71,7 @@ public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
return nil return nil
} }
public func cropToSquare(_ image: UIImage) -> UIImage { func cropToSquare(_ image: UIImage) -> UIImage {
let size = image.size let size = image.size
let side = min(size.width, size.height) let side = min(size.width, size.height)
let newSize = CGSize(width: side, height: side) let newSize = CGSize(width: side, height: side)
@@ -85,7 +84,7 @@ public func cropToSquare(_ image: UIImage) -> UIImage {
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image)) return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image))
} }
public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? { func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
var img = image var img = image
var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0 var dataSize = data?.count ?? 0
@@ -100,7 +99,7 @@ public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha
return data return data
} }
public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? { func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
var img = image var img = image
let hasAlpha = imageHasAlpha(image) let hasAlpha = imageHasAlpha(image)
var str = compressImageStr(img, hasAlpha: hasAlpha) var str = compressImageStr(img, hasAlpha: hasAlpha)
@@ -116,7 +115,7 @@ public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String
return str return str
} }
public func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? { func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
let ext = hasAlpha ? "png" : "jpg" let ext = hasAlpha ? "png" : "jpg"
if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) { if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
return "data:image/\(ext);base64,\(data.base64EncodedString())" return "data:image/\(ext);base64,\(data.base64EncodedString())"
@@ -139,7 +138,7 @@ private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, ha
} }
} }
public func imageHasAlpha(_ img: UIImage) -> Bool { func imageHasAlpha(_ img: UIImage) -> Bool {
if let cgImage = img.cgImage { if let cgImage = img.cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB() let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@@ -159,35 +158,7 @@ public func imageHasAlpha(_ img: UIImage) -> Bool {
return false return false
} }
/// Reduces image size, while consuming less RAM func saveFileFromURL(_ url: URL) -> CryptoFile? {
///
/// Used by ShareExtension to downsize large images
/// before passing them to regular image processing pipeline
/// to avoid exceeding 120MB memory
///
/// - Parameters:
/// - url: Location of the image data
/// - size: Maximum dimension (width or height)
/// - Returns: Downsampled image or `nil`, if the image can't be located
public func downsampleImage(at url: URL, to size: Int64) -> UIImage? {
autoreleasepool {
if let source = CGImageSourceCreateWithURL(url as CFURL, nil) {
CGImageSourceCreateThumbnailAtIndex(
source,
0,
[
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: String(size) as CFString
] as CFDictionary
)
.map { UIImage(cgImage: $0) }
} else { nil }
}
}
public func saveFileFromURL(_ url: URL) -> CryptoFile? {
let encrypted = privacyEncryptLocalFilesGroupDefault.get() let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let savedFile: CryptoFile? let savedFile: CryptoFile?
if url.startAccessingSecurityScopedResource() { if url.startAccessingSecurityScopedResource() {
@@ -213,7 +184,7 @@ public func saveFileFromURL(_ url: URL) -> CryptoFile? {
return savedFile return savedFile
} }
public func moveTempFileFromURL(_ url: URL) -> CryptoFile? { func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
do { do {
let encrypted = privacyEncryptLocalFilesGroupDefault.get() let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let fileName = uniqueCombine(url.lastPathComponent) let fileName = uniqueCombine(url.lastPathComponent)
@@ -226,6 +197,7 @@ public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName)) try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
savedFile = CryptoFile.plain(fileName) savedFile = CryptoFile.plain(fileName)
} }
ChatModel.shared.filesToDelete.remove(url)
return savedFile return savedFile
} catch { } catch {
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)") logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
@@ -233,44 +205,7 @@ public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
} }
} }
public func saveWallpaperFile(url: URL) -> String? { func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true))
do {
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
return destFile.lastPathComponent
} catch {
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
return nil
}
}
public func saveWallpaperFile(image: UIImage) -> String? {
let hasAlpha = imageHasAlpha(image)
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true))
let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha)
do {
try dataResized!.write(to: destFile)
return destFile.lastPathComponent
} catch {
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
return nil
}
}
public func removeWallpaperFile(fileName: String? = nil) {
do {
try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach {
if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) }
}
} catch {
logger.error("FileUtils.removeWallpaperFile error: \(error.localizedDescription)")
}
if let fileName {
WallpaperType.cachedImages.removeValue(forKey: fileName)
}
}
public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath) uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
} }
@@ -302,7 +237,7 @@ private func getTimestamp() -> String {
return df.string(from: Date()) return df.string(from: Date())
} }
public func dropImagePrefix(_ s: String) -> String { func dropImagePrefix(_ s: String) -> String {
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
} }
@@ -310,23 +245,8 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
} }
public func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
s.outputURL = outputUrl
s.outputFileType = .mp4
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
await s.export()
if let err = s.error {
logger.error("Failed to export video with error: \(err)")
}
return s.status == .completed
}
return false
}
extension AVAsset { extension AVAsset {
public func generatePreview() -> (UIImage, Int)? { func generatePreview() -> (UIImage, Int)? {
let generator = AVAssetImageGenerator(asset: self) let generator = AVAssetImageGenerator(asset: self)
generator.appliesPreferredTrackTransform = true generator.appliesPreferredTrackTransform = true
var actualTime = CMTimeMake(value: 0, timescale: 0) var actualTime = CMTimeMake(value: 0, timescale: 0)
@@ -338,7 +258,7 @@ extension AVAsset {
} }
extension UIImage { extension UIImage {
public func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage { func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
if let cgImage = cgImage { if let cgImage = cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB() let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@@ -383,75 +303,4 @@ extension UIImage {
} }
return self return self
} }
public convenience init?(base64Encoded: String?) {
if let base64Encoded, let data = Data(base64Encoded: dropImagePrefix(base64Encoded)) {
self.init(data: data)
} else {
return nil
}
}
}
public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
logger.error("Could not load link preview image")
cb(nil)
}
}
}
public func getLinkPreview(for url: URL) async -> LinkPreview? {
await withCheckedContinuation { cont in
getLinkPreview(url: url) { cont.resume(returning: $0) }
}
}
private let squareToCircleRatio = 0.935
private let radiusFactor = (1 - squareToCircleRatio) / 50
@ViewBuilder public func clipProfileImage(_ img: Image, size: CGFloat, radius: Double, blurred: Bool = false) -> some View {
if radius >= 50 {
blurredFrame(img, size, blurred).clipShape(Circle())
} else if radius <= 0 {
let sz = size * squareToCircleRatio
blurredFrame(img, sz, blurred).padding((size - sz) / 2)
} else {
let sz = size * (squareToCircleRatio + radius * radiusFactor)
blurredFrame(img, sz, blurred)
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
.padding((size - sz) / 2)
}
}
@ViewBuilder private func blurredFrame(_ img: Image, _ size: CGFloat, _ blurred: Bool) -> some View {
let v = img.resizable().frame(width: size, height: size)
if blurred {
v.blur(radius: size / 4)
} else {
v
}
} }
@@ -1,73 +0,0 @@
//
// NetworkObserver.swift
// SimpleX (iOS)
//
// Created by Avently on 05.04.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import Network
import SimpleXChat
class NetworkObserver {
static let shared = NetworkObserver()
private let queue: DispatchQueue = DispatchQueue(label: "chat.simplex.app.NetworkObserver")
private var prevInfo: UserNetworkInfo? = nil
private var monitor: NWPathMonitor?
private let monitorLock: DispatchQueue = DispatchQueue(label: "chat.simplex.app.monitorLock")
func restartMonitor() {
monitorLock.sync {
monitor?.cancel()
let mon = NWPathMonitor()
mon.pathUpdateHandler = { [weak self] path in
self?.networkPathChanged(path: path)
}
mon.start(queue: queue)
monitor = mon
}
}
private func networkPathChanged(path: NWPath) {
let info = UserNetworkInfo(
networkType: networkTypeFromPath(path),
online: path.status == .satisfied
)
if (prevInfo != info) {
prevInfo = info
setNetworkInfo(info)
}
}
private func networkTypeFromPath(_ path: NWPath) -> UserNetworkType {
if path.usesInterfaceType(.wiredEthernet) {
.ethernet
} else if path.usesInterfaceType(.wifi) {
.wifi
} else if path.usesInterfaceType(.cellular) {
.cellular
} else if path.usesInterfaceType(.other) {
.other
} else {
.none
}
}
private static var networkObserver: NetworkObserver? = nil
private func setNetworkInfo(_ info: UserNetworkInfo) {
logger.debug("setNetworkInfo Network changed: \(String(describing: info))")
DispatchQueue.main.sync {
ChatModel.shared.networkInfo = info
}
if !hasChatCtrl() { return }
self.monitorLock.sync {
do {
try apiSetNetworkInfo(info)
} catch let err {
logger.error("setNetworkInfo error: \(responseError(err))")
}
}
}
}
+7 -5
View File
@@ -57,9 +57,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
chatModel.ntfCallInvitationAction = (chatId, ntfAction) chatModel.ntfCallInvitationAction = (chatId, ntfAction)
} }
} else { } else {
if let chatId = content.targetContentIdentifier { chatModel.chatId = content.targetContentIdentifier
ItemsModel.shared.loadOpenChat(chatId)
}
} }
handler() handler()
} }
@@ -240,8 +238,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
ntfBadgeCountGroupDefault.set(count) ntfBadgeCountGroupDefault.set(count)
} }
func changeNtfBadgeCount(by count: Int = 1) { func decNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count)) setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
}
func incNtfBadgeCount(by count: Int = 1) {
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
} }
private func addNotification(_ content: UNMutableNotificationContent) { private func addNotification(_ content: UNMutableNotificationContent) {
+126 -348
View File
@@ -92,22 +92,18 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
return r return r
} }
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) -> ChatResponse { func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
if log { logger.debug("chatSendCmd \(cmd.cmdType)")
logger.debug("chatSendCmd \(cmd.cmdType)")
}
let start = Date.now let start = Date.now
let resp = bgTask let resp = bgTask
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) } ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
: sendSimpleXCmd(cmd, ctrl) : sendSimpleXCmd(cmd, ctrl)
if log { logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") if case let .response(_, json) = resp {
if case let .response(_, json) = resp { logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") }
} Task {
Task { await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
}
} }
return resp return resp
} }
@@ -137,8 +133,8 @@ func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
} }
} }
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User { func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl) let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl)
if case let .activeUser(user) = r { return user } if case let .activeUser(user) = r { return user }
throw r throw r
} }
@@ -217,7 +213,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
} }
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool { func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl) let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
switch r { switch r {
case .chatStarted: return true case .chatStarted: return true
case .chatRunning: return false case .chatRunning: return false
@@ -225,15 +221,6 @@ func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
} }
} }
func apiCheckChatRunning() throws -> Bool {
let r = chatSendCmdSync(.checkChatRunning)
switch r {
case .chatRunning: return true
case .chatStopped: return false
default: throw r
}
}
func apiStopChat() async throws { func apiStopChat() async throws {
let r = await chatSendCmd(.apiStopChat) let r = await chatSendCmd(.apiStopChat)
switch r { switch r {
@@ -255,8 +242,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
logger.error("apiSuspendChat error: \(String(describing: r))") logger.error("apiSuspendChat error: \(String(describing: r))")
} }
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws { func apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws {
let r = chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl) let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl)
if case .cmdOk = r { return }
throw r
}
func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws {
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), ctrl)
if case .cmdOk = r { return } if case .cmdOk = r { return }
throw r throw r
} }
@@ -279,12 +272,22 @@ func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
throw r throw r
} }
func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] { func apiSetPQEncryption(_ enable: Bool) throws {
let r = await chatSendCmd(.apiExportArchive(config: config)) let r = chatSendCmdSync(.apiSetPQEncryption(enable: enable))
if case let .archiveExported(archiveErrors) = r { return archiveErrors } if case .cmdOk = r { return }
throw r throw r
} }
func apiSetContactPQ(_ contactId: Int64, _ enable: Bool) async throws -> Contact {
let r = await chatSendCmd(.apiSetContactPQ(contactId: contactId, enable: enable))
if case let .contactPQAllowed(_, contact, _) = r { return contact }
throw r
}
func apiExportArchive(config: ArchiveConfig) async throws {
try await sendCommandOkResp(.apiExportArchive(config: config))
}
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] { func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
let r = await chatSendCmd(.apiImportArchive(config: config)) let r = await chatSendCmd(.apiImportArchive(config: config))
if case let .archiveImported(archiveErrors) = r { return archiveErrors } if case let .archiveImported(archiveErrors) = r { return archiveErrors }
@@ -318,10 +321,8 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
throw r throw r
} }
let loadItemsPerPage = 50 func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search))
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat) = r { return Chat.init(chat) } if case let .apiChat(_, chat) = r { return Chat.init(chat) }
throw r throw r
} }
@@ -332,20 +333,15 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
throw r throw r
} }
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { func loadChat(chat: Chat, search: String = "") {
do { do {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
let m = ChatModel.shared let m = ChatModel.shared
let im = ItemsModel.shared
m.chatItemStatuses = [:] m.chatItemStatuses = [:]
if clearItems { m.reversedChatItems = []
await MainActor.run { im.reversedChatItems = [] } let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
} m.updateChatInfo(chat.chatInfo)
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) m.reversedChatItems = chat.chatItems.reversed()
await MainActor.run {
im.reversedChatItems = chat.chatItems.reversed()
m.updateChatInfo(chat.chatInfo)
}
} catch let error { } catch let error {
logger.error("loadChat error: \(responseError(error))") logger.error("loadChat error: \(responseError(error))")
} }
@@ -357,20 +353,11 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
throw r throw r
} }
func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? {
let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
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: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
}
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? {
let chatModel = ChatModel.shared 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 let r: ChatResponse
if toChatType == .direct { if type == .direct {
var cItem: ChatItem? = nil var cItem: ChatItem? = nil
let endTask = beginBGTask({ let endTask = beginBGTask({
if let cItem = cItem { if let cItem = cItem {
@@ -410,10 +397,10 @@ func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent)
} }
private func sendMessageErrorAlert(_ r: ChatResponse) { private func sendMessageErrorAlert(_ r: ChatResponse) {
logger.error("send message error: \(String(describing: r))") logger.error("apiSendMessage error: \(String(describing: r))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Error sending message", title: "Error sending message",
message: "Error: \(responseError(r))" message: "Error: \(String(describing: r))"
) )
} }
@@ -421,7 +408,7 @@ private func createChatItemErrorAlert(_ r: ChatResponse) {
logger.error("apiCreateChatItem error: \(String(describing: r))") logger.error("apiCreateChatItem error: \(String(describing: r))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Error creating message", title: "Error creating message",
message: "Error: \(responseError(r))" message: "Error: \(String(describing: r))"
) )
} }
@@ -437,15 +424,15 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
throw r throw r
} }
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay) let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items } if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
throw r throw r
} }
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] { func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay) let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, groupMemberId: groupMemberId, itemId: itemId), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items } if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
throw r throw r
} }
@@ -547,21 +534,10 @@ func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws {
throw r throw r
} }
func apiSetNetworkInfo(_ networkInfo: UserNetworkInfo) throws {
let r = chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo))
if case .cmdOk = r { return }
throw r
}
func reconnectAllServers() async throws { func reconnectAllServers() async throws {
try await sendCommandOkResp(.reconnectAllServers) try await sendCommandOkResp(.reconnectAllServers)
} }
func reconnectServer(smpServer: String) async throws {
let userId = try currentUserId("reconnectServer")
try await sendCommandOkResp(.reconnectServer(userId: userId, smpServer: smpServer))
}
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws { func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings)) try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
} }
@@ -582,18 +558,6 @@ func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (Gro
throw r throw r
} }
func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
throw r
}
func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
throw r
}
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats { func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId)) let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId))
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats } if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
@@ -705,7 +669,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
return ((.contact, connection), nil) return ((.contact, connection), nil)
case let .contactAlreadyExists(_, contact): case let .contactAlreadyExists(_, contact):
if let c = m.getContactChat(contact.contactId) { if let c = m.getContactChat(contact.contactId) {
ItemsModel.shared.loadOpenChat(c.id) await MainActor.run { m.chatId = c.id }
} }
let alert = contactAlreadyExistsAlert(contact) let alert = contactAlreadyExistsAlert(contact)
return (nil, alert) return (nil, alert)
@@ -715,7 +679,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
message: "Please check that you used the correct link or ask your contact to send you another one." message: "Please check that you used the correct link or ask your contact to send you another one."
) )
return (nil, alert) return (nil, alert)
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
let alert = mkAlert( let alert = mkAlert(
title: "Connection error (AUTH)", title: "Connection error (AUTH)",
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
@@ -748,7 +712,7 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
} else { } else {
return mkAlert( return mkAlert(
title: "Connection error", title: "Connection error",
message: "Error: \(responseError(r))" message: "Error: \(String(describing: r))"
) )
} }
} }
@@ -765,38 +729,22 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
return (nil, alert) return (nil, alert)
} }
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws { func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
let chatId = type.rawValue + id.description let chatId = type.rawValue + id.description
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) } DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } } defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false) let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
if case .direct = type, case .contactDeleted = r { return } if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return }
if case .group = type, case .groupDeletedUser = r { return } if case .group = type, case .groupDeletedUser = r { return }
throw r throw r
} }
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact { func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
let type: ChatType = .direct
let chatId = type.rawValue + id.description
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
}
defer {
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
}
}
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case let .contactDeleted(_, contact) = r { return contact }
throw r
}
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
do { do {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode) try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
await MainActor.run { ChatModel.shared.removeChat(cInfo.id) } DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
} catch let error { } catch let error {
logger.error("deleteChat apiDeleteChat error: \(responseError(error))") logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -806,39 +754,6 @@ func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: tru
} }
} }
func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? {
do {
let cInfo = chat.chatInfo
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
await MainActor.run {
switch chatDeleteMode {
case .full:
ChatModel.shared.removeChat(cInfo.id)
case .entity:
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: chat.chatItems
))
case .messages:
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: []
))
}
}
} catch let error {
logger.error("deleteContactChat apiDeleteContact error: \(responseError(error))")
return mkAlert(
title: "Error deleting chat!",
message: "Error: \(responseError(error))"
)
}
return nil
}
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo { func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo } if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
@@ -901,21 +816,6 @@ func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> Pe
throw r throw r
} }
func apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) async -> Bool {
let r = await chatSendCmd(.apiSetUserUIThemes(userId: userId, themes: themes))
if case .cmdOk = r { return true }
logger.error("apiSetUserUIThemes bad response: \(String(describing: r))")
return false
}
func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bool {
let r = await chatSendCmd(.apiSetChatUIThemes(chatId: chatId, themes: themes))
if case .cmdOk = r { return true }
logger.error("apiSetChatUIThemes bad response: \(String(describing: r))")
return false
}
func apiCreateUserAddress() async throws -> String { func apiCreateUserAddress() async throws -> String {
let userId = try currentUserId("apiCreateUserAddress") let userId = try currentUserId("apiCreateUserAddress")
let r = await chatSendCmd(.apiCreateMyAddress(userId: userId)) let r = await chatSendCmd(.apiCreateMyAddress(userId: userId))
@@ -963,7 +863,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
let am = AlertManager.shared let am = AlertManager.shared
if case let .acceptingContactRequest(_, contact) = r { return contact } if case let .acceptingContactRequest(_, contact) = r { return contact }
if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r { if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r {
am.showAlertMsg( am.showAlertMsg(
title: "Connection error (AUTH)", title: "Connection error (AUTH)",
message: "Sender may have deleted the connection request." message: "Sender may have deleted the connection request."
@@ -974,7 +874,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
logger.error("apiAcceptContactRequest error: \(String(describing: r))") logger.error("apiAcceptContactRequest error: \(String(describing: r))")
am.showAlertMsg( am.showAlertMsg(
title: "Error accepting contact request", title: "Error accepting contact request",
message: "Error: \(responseError(r))" message: "Error: \(String(describing: r))"
) )
} }
return nil return nil
@@ -1000,7 +900,7 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
return (fileTransferMeta, nil) return (fileTransferMeta, nil)
} else { } else {
logger.error("uploadStandaloneFile error: \(String(describing: r))") logger.error("uploadStandaloneFile error: \(String(describing: r))")
return (nil, responseError(r)) return (nil, String(describing: r))
} }
} }
@@ -1010,7 +910,7 @@ func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, c
return (rcvFileTransfer, nil) return (rcvFileTransfer, nil)
} else { } else {
logger.error("downloadStandaloneFile error: \(String(describing: r))") logger.error("downloadStandaloneFile error: \(String(describing: r))")
return (nil, responseError(r)) return (nil, String(describing: r))
} }
} }
@@ -1024,19 +924,14 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF
} }
} }
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async { func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async {
if let chatItem = await apiReceiveFile( if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) {
fileId: fileId,
userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
encrypted: privacyEncryptLocalFilesGroupDefault.get(),
auto: auto
) {
await chatItemSimpleUpdate(user, chatItem) await chatItemSimpleUpdate(user, chatItem)
} }
} }
func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? { func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
let r = await chatSendCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline)) let r = await chatSendCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline))
let am = AlertManager.shared let am = AlertManager.shared
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
if case .rcvFileAcceptedSndCancelled = r { if case .rcvFileAcceptedSndCancelled = r {
@@ -1049,50 +944,19 @@ func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, in
} }
} else if let networkErrorAlert = networkErrorAlert(r) { } else if let networkErrorAlert = networkErrorAlert(r) {
logger.error("apiReceiveFile network error: \(String(describing: r))") logger.error("apiReceiveFile network error: \(String(describing: r))")
if !auto { am.showAlert(networkErrorAlert)
am.showAlert(networkErrorAlert)
}
} else { } else {
switch chatError(r) { switch chatError(r) {
case .fileCancelled: case .fileCancelled:
logger.debug("apiReceiveFile ignoring fileCancelled error") logger.debug("apiReceiveFile ignoring fileCancelled error")
case .fileAlreadyReceiving: case .fileAlreadyReceiving:
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error") logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
case let .fileNotApproved(fileId, unknownServers):
logger.debug("apiReceiveFile fileNotApproved error")
if !auto {
let srvs = unknownServers.map { s in
if let srv = parseServerAddress(s), !srv.hostnames.isEmpty {
srv.hostnames[0]
} else {
serverHost(s)
}
}
am.showAlert(Alert(
title: Text("Unknown servers!"),
message: Text("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs.sorted().joined(separator: ", "))."),
primaryButton: .default(
Text("Download"),
action: {
Task {
logger.debug("apiReceiveFile fileNotApproved alert - in Task")
if let user = ChatModel.shared.currentUser {
await receiveFile(user: user, fileId: fileId, userApprovedRelays: true)
}
}
}
),
secondaryButton: .cancel()
))
}
default: default:
logger.error("apiReceiveFile error: \(String(describing: r))") logger.error("apiReceiveFile error: \(String(describing: r))")
if !auto { am.showAlertMsg(
am.showAlertMsg( title: "Error receiving file",
title: "Error receiving file", message: "Error: \(String(describing: r))"
message: "Error: \(responseError(r))" )
)
}
} }
} }
return nil return nil
@@ -1157,9 +1021,18 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
} }
func networkErrorAlert(_ r: ChatResponse) -> Alert? { func networkErrorAlert(_ r: ChatResponse) -> Alert? {
if let alert = getNetworkErrorAlert(r) { switch r {
return mkAlert(title: alert.title, message: alert.message) case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
} else { return mkAlert(
title: "Connection timeout",
message: "Please check your network connection with \(serverHostname(addr)) and try again."
)
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
return mkAlert(
title: "Connection error",
message: "Please check your network connection with \(serverHostname(addr)) and try again."
)
default:
return nil return nil
} }
} }
@@ -1167,17 +1040,7 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async { func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) { if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
await MainActor.run { DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
ChatModel.shared.replaceChat(contactRequest.id, chat)
NetworkModel.shared.setContactNetworkStatus(contact, .connected)
}
if contact.sndReady {
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(chat.id)
}
}
}
} }
} }
@@ -1273,7 +1136,7 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do { do {
logger.debug("apiMarkChatItemRead: \(cItem.id)") logger.debug("apiMarkChatItemRead: \(cItem.id)")
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
await ChatModel.shared.markChatItemRead(cInfo, cItem) await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
} catch { } catch {
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))") logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
} }
@@ -1314,7 +1177,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId)) let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
switch r { switch r {
case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo) case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved
case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
default: throw r default: throw r
} }
@@ -1363,7 +1226,7 @@ func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil } let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
return ChatModel.shared.chats return ChatModel.shared.chats
.compactMap{ $0.chatInfo.contact } .compactMap{ $0.chatInfo.contact }
.filter{ c in c.sendMsgEnabled && !c.nextSendGrpInv && !memberContactIds.contains(c.apiId) } .filter{ c in c.ready && c.active && !memberContactIds.contains(c.apiId) }
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() } .sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
} }
@@ -1420,26 +1283,6 @@ func apiGetVersion() throws -> CoreVersionInfo {
throw r throw r
} }
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
let userId = try currentUserId("getAgentSubsTotal")
let r = await chatSendCmd(.getAgentSubsTotal(userId: userId))
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))")
throw r
}
func getAgentServersSummary() throws -> PresentedServersSummary {
let userId = try currentUserId("getAgentServersSummary")
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
if case let .agentServersSummary(_, serversSummary) = r { return serversSummary }
logger.error("getAgentServersSummary error: \(String(describing: r))")
throw r
}
func resetAgentServersStats() async throws {
try await sendCommandOkResp(.resetAgentServersStats)
}
private func currentUserId(_ funcName: String) throws -> Int64 { private func currentUserId(_ funcName: String) throws -> Int64 {
if let userId = ChatModel.shared.currentUser?.userId { if let userId = ChatModel.shared.currentUser?.userId {
return userId return userId
@@ -1454,13 +1297,14 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
defer { m.ctrlInitInProgress = false } defer { m.ctrlInitInProgress = false }
(m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey, confirmMigrations: confirmMigrations) (m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey, confirmMigrations: confirmMigrations)
if m.chatDbStatus != .ok { return } if m.chatDbStatus != .ok { return }
NetworkObserver.shared.restartMonitor()
// If we migrated successfully means previous re-encryption process on database level finished successfully too // If we migrated successfully means previous re-encryption process on database level finished successfully too
if encryptionStartedDefault.get() { if encryptionStartedDefault.get() {
encryptionStartedDefault.set(false) encryptionStartedDefault.set(false)
} }
try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path) try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
try apiSetPQEncryption(pqExperimentalEnabledDefault.get())
m.chatInitialized = true m.chatInitialized = true
m.currentUser = try apiGetActiveUser() m.currentUser = try apiGetActiveUser()
if m.currentUser == nil { if m.currentUser == nil {
@@ -1511,16 +1355,15 @@ func startChat(refreshInvitations: Bool = true) throws {
logger.debug("startChat") logger.debug("startChat")
let m = ChatModel.shared let m = ChatModel.shared
try setNetworkConfig(getNetCfg()) try setNetworkConfig(getNetCfg())
let chatRunning = try apiCheckChatRunning() let justStarted = try apiStartChat()
m.users = try listUsers() m.users = try listUsers()
if !chatRunning { if justStarted {
try getUserChatData() try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers()) NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
if (refreshInvitations) { if (refreshInvitations) {
try refreshCallInvitations() try refreshCallInvitations()
} }
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken() (m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
_ = try apiStartChat()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called, // deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat // when it is called before startChat
if let token = m.deviceToken { if let token = m.deviceToken {
@@ -1545,7 +1388,8 @@ func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
logger.debug("startChatWithTemporaryDatabase") logger.debug("startChatWithTemporaryDatabase")
let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl) let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl)
try setNetworkConfig(getNetCfg(), ctrl: ctrl) try setNetworkConfig(getNetCfg(), ctrl: ctrl)
try apiSetAppFilePaths(filesFolder: getMigrationTempFilesDirectory().path, tempFolder: getMigrationTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path, ctrl: ctrl) try apiSetTempFolder(tempFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
try apiSetFilesFolder(filesFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl)
_ = try apiStartChat(ctrl: ctrl) _ = try apiStartChat(ctrl: ctrl)
return migrationActiveUser return migrationActiveUser
} }
@@ -1592,7 +1436,6 @@ func getUserChatData() throws {
m.chatItemTTL = try getChatItemTTL() m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats() let chats = try apiGetChats()
m.chats = chats.map { Chat.init($0) } m.chats = chats.map { Chat.init($0) }
m.popChatCollector.clear()
} }
private func getUserChatDataAsync() async throws { private func getUserChatDataAsync() async throws {
@@ -1605,13 +1448,11 @@ private func getUserChatDataAsync() async throws {
m.userAddress = userAddress m.userAddress = userAddress
m.chatItemTTL = chatItemTTL m.chatItemTTL = chatItemTTL
m.chats = chats.map { Chat.init($0) } m.chats = chats.map { Chat.init($0) }
m.popChatCollector.clear()
} }
} else { } else {
await MainActor.run { await MainActor.run {
m.userAddress = nil m.userAddress = nil
m.chats = [] m.chats = []
m.popChatCollector.clear()
} }
} }
} }
@@ -1621,8 +1462,6 @@ class ChatReceiver {
private var receiveMessages = true private var receiveMessages = true
private var _lastMsgTime = Date.now private var _lastMsgTime = Date.now
var messagesChannel: ((ChatResponse) -> Void)? = nil
static let shared = ChatReceiver() static let shared = ChatReceiver()
var lastMsgTime: Date { get { _lastMsgTime } } var lastMsgTime: Date { get { _lastMsgTime } }
@@ -1640,9 +1479,6 @@ class ChatReceiver {
if let msg = await chatRecvMsg() { if let msg = await chatRecvMsg() {
self._lastMsgTime = .now self._lastMsgTime = .now
await processReceivedMsg(msg) await processReceivedMsg(msg)
if let messagesChannel {
messagesChannel(msg)
}
} }
_ = try? await Task.sleep(nanoseconds: 7_500_000) _ = try? await Task.sleep(nanoseconds: 7_500_000)
} }
@@ -1661,7 +1497,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
await TerminalItems.shared.add(.resp(.now, res)) await TerminalItems.shared.add(.resp(.now, res))
} }
let m = ChatModel.shared let m = ChatModel.shared
let n = NetworkModel.shared
logger.debug("processReceivedMsg: \(res.responseType)") logger.debug("processReceivedMsg: \(res.responseType)")
switch res { switch res {
case let .contactDeletedByContact(user, contact): case let .contactDeletedByContact(user, contact):
@@ -1684,7 +1519,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
NtfManager.shared.notifyContactConnected(user, contact) NtfManager.shared.notifyContactConnected(user, contact)
} }
await MainActor.run { await MainActor.run {
n.setContactNetworkStatus(contact, .connected) m.setContactNetworkStatus(contact, .connected)
} }
case let .contactConnecting(user, contact): case let .contactConnecting(user, contact):
if active(user) && contact.directOrUsed { if active(user) && contact.directOrUsed {
@@ -1696,19 +1531,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
} }
} }
case let .contactSndReady(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.removeChat(conn.id)
}
}
}
await MainActor.run {
n.setContactNetworkStatus(contact, .connected)
}
case let .receivedContactRequest(user, contactRequest): case let .receivedContactRequest(user, contactRequest):
if active(user) { if active(user) {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest) let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
@@ -1741,7 +1563,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
if active(user) && m.hasChat(mergedContact.id) { if active(user) && m.hasChat(mergedContact.id) {
await MainActor.run { await MainActor.run {
if m.chatId == mergedContact.id { if m.chatId == mergedContact.id {
ItemsModel.shared.loadOpenChat(mergedContact.id) m.chatId = intoContact.id
} }
m.removeChat(mergedContact.id) m.removeChat(mergedContact.id)
} }
@@ -1749,27 +1571,27 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .networkStatus(status, connections): case let .networkStatus(status, connections):
// dispatch queue to synchronize access // dispatch queue to synchronize access
networkStatusesLock.sync { networkStatusesLock.sync {
var ns = n.networkStatuses var ns = m.networkStatuses
// slow loop is on the background thread // slow loop is on the background thread
for cId in connections { for cId in connections {
ns[cId] = status ns[cId] = status
} }
// fast model update is on the main thread // fast model update is on the main thread
DispatchQueue.main.sync { DispatchQueue.main.sync {
n.networkStatuses = ns m.networkStatuses = ns
} }
} }
case let .networkStatuses(_, statuses): () case let .networkStatuses(_, statuses): ()
// dispatch queue to synchronize access // dispatch queue to synchronize access
networkStatusesLock.sync { networkStatusesLock.sync {
var ns = n.networkStatuses var ns = m.networkStatuses
// slow loop is on the background thread // slow loop is on the background thread
for s in statuses { for s in statuses {
ns[s.agentConnId] = s.networkStatus ns[s.agentConnId] = s.networkStatus
} }
// fast model update is on the main thread // fast model update is on the main thread
DispatchQueue.main.sync { DispatchQueue.main.sync {
n.networkStatuses = ns m.networkStatuses = ns
} }
} }
case let .newChatItem(user, aChatItem): case let .newChatItem(user, aChatItem):
@@ -1812,25 +1634,21 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem) m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
} }
} }
case let .chatItemsDeleted(user, items, _): case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
if !active(user) { if !active(user) {
for item in items { if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled { await MainActor.run {
await MainActor.run { m.decreaseUnreadCounter(user: user)
m.decreaseUnreadCounter(user: user)
}
} }
} }
return return
} }
await MainActor.run { await MainActor.run {
for item in items { if let toChatItem = toChatItem {
if let toChatItem = item.toChatItem { _ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem) } else {
} else { m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
}
} }
} }
case let .receivedGroupInvitation(user, groupInfo, _, _): case let .receivedGroupInvitation(user, groupInfo, _, _):
@@ -1910,7 +1728,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
if let contact = memberContact { if let contact = memberContact {
await MainActor.run { await MainActor.run {
n.setContactNetworkStatus(contact, .connected) m.setContactNetworkStatus(contact, .connected)
} }
} }
case let .groupUpdated(user, toGroup): case let .groupUpdated(user, toGroup):
@@ -1952,15 +1770,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
if let aChatItem = aChatItem { if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
} }
case let .rcvFileError(user, aChatItem, _, _): case let .rcvFileError(user, aChatItem, _):
if let aChatItem = aChatItem { if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) } Task { cleanupFile(aChatItem) }
} }
case let .rcvFileWarning(user, aChatItem, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
}
case let .sndFileStart(user, aChatItem, _): case let .sndFileStart(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
case let .sndFileComplete(user, aChatItem, _): case let .sndFileComplete(user, aChatItem, _):
@@ -1977,15 +1791,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
case let .sndFileCompleteXFTP(user, aChatItem, _): case let .sndFileCompleteXFTP(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
case let .sndFileError(user, aChatItem, _, _): Task { cleanupFile(aChatItem) }
case let .sndFileError(user, aChatItem, _):
if let aChatItem = aChatItem { if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem) await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) } Task { cleanupFile(aChatItem) }
} }
case let .sndFileWarning(user, aChatItem, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
}
case let .callInvitation(invitation): case let .callInvitation(invitation):
await MainActor.run { await MainActor.run {
m.callInvitations[invitation.contact.id] = invitation m.callInvitations[invitation.contact.id] = invitation
@@ -2031,35 +1842,21 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
case .chatSuspended: case .chatSuspended:
chatSuspended() chatSuspended()
case let .contactSwitch(user, contact, switchProgress): case let .contactSwitch(_, contact, switchProgress):
if active(user) { await MainActor.run {
await MainActor.run { m.updateContactConnectionStats(contact, switchProgress.connectionStats)
m.updateContactConnectionStats(contact, switchProgress.connectionStats)
}
} }
case let .groupMemberSwitch(user, groupInfo, member, switchProgress): case let .groupMemberSwitch(_, groupInfo, member, switchProgress):
if active(user) { await MainActor.run {
await MainActor.run { m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
}
} }
case let .contactRatchetSync(user, contact, ratchetSyncProgress): case let .contactRatchetSync(_, contact, ratchetSyncProgress):
if active(user) { await MainActor.run {
await MainActor.run { m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
}
} }
case let .groupMemberRatchetSync(user, groupInfo, member, ratchetSyncProgress): case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress):
if active(user) { await MainActor.run {
await MainActor.run { m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
}
}
case let .contactDisabled(user, contact):
if active(user) {
await MainActor.run {
m.updateContact(contact)
}
} }
case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible):
await MainActor.run { await MainActor.run {
@@ -2083,30 +1880,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "") let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "")
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state) m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
} }
case let .remoteCtrlStopped(_, rcStopReason): case .remoteCtrlStopped:
// This delay is needed to cancel the session that fails on network failure, // This delay is needed to cancel the session that fails on network failure,
// e.g. when user did not grant permission to access local network yet. // e.g. when user did not grant permission to access local network yet.
if let sess = m.remoteCtrlSession { if let sess = m.remoteCtrlSession {
await MainActor.run { await MainActor.run {
m.remoteCtrlSession = nil m.remoteCtrlSession = nil
dismissAllSheets() {
switch rcStopReason {
case .disconnected:
()
case .connectionFailed(.errorAgent(.RCP(.identity))):
AlertManager.shared.showAlertMsg(
title: "Connection with desktop stopped",
message: "This link was used with another mobile device, please create a new link on the desktop."
)
default:
AlertManager.shared.showAlert(Alert(
title: Text("Connection with desktop stopped"),
message: Text("Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers."),
primaryButton: .default(Text("Ok")),
secondaryButton: .default(Text("Copy error")) { UIPasteboard.general.string = String(describing: rcStopReason) }
))
}
}
} }
if case .connected = sess.sessionState { if case .connected = sess.sessionState {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
@@ -2135,13 +1914,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
func switchToLocalSession() { func switchToLocalSession() {
let m = ChatModel.shared let m = ChatModel.shared
let n = NetworkModel.shared
m.remoteCtrlSession = nil m.remoteCtrlSession = nil
do { do {
m.users = try listUsers() m.users = try listUsers()
try getUserChatData() try getUserChatData()
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) } let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses) m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
} catch let error { } catch let error {
logger.debug("error updating chat data: \(responseError(error))") logger.debug("error updating chat data: \(responseError(error))")
} }
-12
View File
@@ -36,18 +36,6 @@ private func _suspendChat(timeout: Int) {
} }
} }
let seSubscriber = seMessageSubscriber {
switch $0 {
case let .state(state):
switch state {
case .inactive:
if AppChatState.shared.value.inactive { activateChat() }
case .sendingMessage:
if AppChatState.shared.value.canSuspend { suspendChat() }
}
}
}
func suspendChat() { func suspendChat() {
suspendLockQueue.sync { suspendLockQueue.sync {
_suspendChat(timeout: appSuspendTimeout) _suspendChat(timeout: appSuspendTimeout)
+5 -5
View File
@@ -22,8 +22,10 @@ struct SimpleXApp: App {
init() { init() {
DispatchQueue.global(qos: .background).sync { DispatchQueue.global(qos: .background).sync {
haskell_init() // we have to use debug profile file name without extension here because .hp extension is added by profiler
// hs_init(0, nil) // haskell_init(0, nil, nil)
let dummyHpPath = getAppDebugProfilePrefixPath().path + "_dummy" // not used, but required to populate eventlog
haskell_init(0, getAppEventLogPath().path, dummyHpPath)
} }
UserDefaults.standard.register(defaults: appDefaults) UserDefaults.standard.register(defaults: appDefaults)
setGroupDefaults() setGroupDefaults()
@@ -39,7 +41,6 @@ struct SimpleXApp: App {
// so that it's computed by the time view renders, and not on event after rendering // so that it's computed by the time view renders, and not on event after rendering
ContentView(contentAccessAuthenticationExtended: !authenticationExpired()) ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
.environmentObject(chatModel) .environmentObject(chatModel)
.environmentObject(AppTheme.shared)
.onOpenURL { url in .onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)") logger.debug("ContentView.onOpenURL: \(url)")
chatModel.appOpenUrl = url chatModel.appOpenUrl = url
@@ -58,7 +59,6 @@ struct SimpleXApp: App {
} }
.onChange(of: scenePhase) { phase in .onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
AppSheetState.shared.scenePhaseActive = phase == .active
switch (phase) { switch (phase) {
case .background: case .background:
// --- authentication // --- authentication
@@ -136,7 +136,7 @@ struct SimpleXApp: App {
chatModel.updateChats(with: chats) chatModel.updateChats(with: chats)
if let id = chatModel.chatId, if let id = chatModel.chatId,
let chat = chatModel.getChat(id) { let chat = chatModel.getChat(id) {
Task { await loadChat(chat: chat, clearItems: false) } loadChat(chat: chat)
} }
if let ncr = chatModel.ntfContactRequest { if let ncr = chatModel.ntfContactRequest {
chatModel.ntfContactRequest = nil chatModel.ntfContactRequest = nil
-199
View File
@@ -1,199 +0,0 @@
//
// Theme.swift
// SimpleX (iOS)
//
// Created by Avently on 14.06.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } }
var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 }
func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight }
class AppTheme: ObservableObject, Equatable {
static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper)
var name: String
var base: DefaultTheme
@ObservedObject var colors: Colors
@ObservedObject var appColors: AppColors
@ObservedObject var wallpaper: AppWallpaper
init(name: String, base: DefaultTheme, colors: Colors, appColors: AppColors, wallpaper: AppWallpaper) {
self.name = name
self.base = base
self.colors = colors
self.appColors = appColors
self.wallpaper = wallpaper
}
static func == (lhs: AppTheme, rhs: AppTheme) -> Bool {
lhs.name == rhs.name &&
lhs.colors == rhs.colors &&
lhs.appColors == rhs.appColors &&
lhs.wallpaper == rhs.wallpaper
}
func updateFromCurrentColors() {
objectWillChange.send()
name = CurrentColors.name
base = CurrentColors.base
colors.updateColorsFrom(CurrentColors.colors)
appColors.updateColorsFrom(CurrentColors.appColors)
wallpaper.updateWallpaperFrom(CurrentColors.wallpaper)
}
}
struct ThemedBackground: ViewModifier {
@EnvironmentObject var theme: AppTheme
var grouped: Bool = false
func body(content: Content) -> some View {
content
.background(
theme.base == DefaultTheme.SIMPLEX
? LinearGradient(
colors: [
grouped
? theme.colors.background.lighter(0.4).asGroupedBackground(theme.base.mode)
: theme.colors.background.lighter(0.4),
grouped
? theme.colors.background.darker(0.4).asGroupedBackground(theme.base.mode)
: theme.colors.background.darker(0.4)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
: LinearGradient(
colors: [],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.background(
theme.base == DefaultTheme.SIMPLEX
? Color.clear
: grouped
? theme.colors.background.asGroupedBackground(theme.base.mode)
: theme.colors.background
)
}
}
var systemInDarkThemeCurrently: Bool {
return UITraitCollection.current.userInterfaceStyle == .dark
}
func reactOnDarkThemeChanges() {
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == systemInDarkThemeCurrently {
// Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
}
extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper {
if preset == nil, let image {
// Need to save image from string and to save its path
if let parsed = UIImage(base64Encoded: image),
let filename = saveWallpaperFile(image: parsed) {
var copy = self
copy.image = nil
copy.imageFile = filename
return copy
} else {
return ThemeWallpaper()
}
} else {
return self
}
}
func withFilledWallpaperBase64() -> ThemeWallpaper {
let aw = toAppWallpaper()
let type = aw.type
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
return ThemeWallpaper (
preset: preset,
scale: scale,
scaleType: scaleType,
background: aw.background?.toReadableHex(),
tint: aw.tint?.toReadableHex(),
image: image,
imageFile: nil
)
}
}
extension ThemeModeOverride {
func removeSameColors(_ base: DefaultTheme, colorsToCompare tc: ThemeColors) -> ThemeModeOverride {
let wallpaperType = WallpaperType.from(wallpaper) ?? WallpaperType.empty
let w: ThemeWallpaper
switch wallpaperType {
case let WallpaperType.preset(filename, scale):
let p = PresetWallpaper.from(filename)
w = ThemeWallpaper(
preset: filename,
scale: scale ?? wallpaper?.scale,
scaleType: nil,
background: p?.background[base]?.toReadableHex(),
tint: p?.tint[base]?.toReadableHex(),
image: nil,
imageFile: nil
)
case WallpaperType.image:
w = ThemeWallpaper(
preset: nil,
scale: nil,
scaleType: WallpaperScaleType.fill,
background: Color.clear.toReadableHex(),
tint: Color.clear.toReadableHex(),
image: nil,
imageFile: nil
)
default:
w = ThemeWallpaper()
}
let wallpaper: ThemeWallpaper? = if let wallpaper {
ThemeWallpaper(
preset: wallpaper.preset,
scale: wallpaper.scale != w.scale ? wallpaper.scale : nil,
scaleType: wallpaper.scaleType != w.scaleType ? wallpaper.scaleType : nil,
background: wallpaper.background != w.background ? wallpaper.background : nil,
tint: wallpaper.tint != w.tint ? wallpaper.tint : nil,
image: wallpaper.image,
imageFile: wallpaper.imageFile
)
} else {
nil
}
return ThemeModeOverride(
mode: self.mode,
colors: ThemeColors(
primary: colors.primary != tc.primary ? colors.primary : nil,
primaryVariant: colors.primaryVariant != tc.primaryVariant ? colors.primaryVariant : nil,
secondary: colors.secondary != tc.secondary ? colors.secondary : nil,
secondaryVariant: colors.secondaryVariant != tc.secondaryVariant ? colors.secondaryVariant : nil,
background: colors.background != tc.background ? colors.background : nil,
surface: colors.surface != tc.surface ? colors.surface : nil,
title: colors.title != tc.title ? colors.title : nil,
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
receivedQuote: colors.receivedQuote != tc.receivedQuote ? colors.receivedQuote : nil
),
wallpaper: wallpaper
)
}
}
-303
View File
@@ -1,303 +0,0 @@
//
// ThemeManager.swift
// SimpleX (iOS)
//
// Created by Avently on 03.06.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
class ThemeManager {
struct ActiveTheme: Equatable {
let name: String
let base: DefaultTheme
let colors: Colors
let appColors: AppColors
var wallpaper: AppWallpaper = AppWallpaper(background: nil, tint: nil, type: .empty)
func toAppTheme() -> AppTheme {
AppTheme(name: name, base: base, colors: colors, appColors: appColors, wallpaper: wallpaper)
}
}
private static func systemDarkThemeColors() -> (Colors, DefaultTheme) {
switch systemDarkThemeDefault.get() {
case DefaultTheme.DARK.themeName: (DarkColorPalette, DefaultTheme.DARK)
case DefaultTheme.SIMPLEX.themeName: (SimplexColorPalette, DefaultTheme.SIMPLEX)
case DefaultTheme.BLACK.themeName: (BlackColorPalette, DefaultTheme.BLACK)
default: (SimplexColorPalette, DefaultTheme.SIMPLEX)
}
}
private static func nonSystemThemeName() -> String {
let themeName = currentThemeDefault.get()
return if themeName != DefaultTheme.SYSTEM_THEME_NAME {
themeName
} else {
systemInDarkThemeCurrently ? systemDarkThemeDefault.get() : DefaultTheme.LIGHT.themeName
}
}
static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? {
let nonSystemThemeName = nonSystemThemeName()
let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName]
return appSettingsTheme.getTheme(defaultThemeId)
}
static func defaultActiveTheme(_ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ThemeModeOverride {
let perUserTheme = !CurrentColors.colors.isLight ? perUserTheme?.dark : perUserTheme?.light
if let perUserTheme {
return perUserTheme
}
let defaultTheme = defaultActiveTheme(appSettingsTheme)
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper)
}
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
let themeName = currentThemeDefault.get()
let nonSystemThemeName = nonSystemThemeName()
let defaultTheme = defaultActiveTheme(appSettingsTheme)
let baseTheme = switch nonSystemThemeName {
case DefaultTheme.LIGHT.themeName: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
case DefaultTheme.DARK.themeName: ActiveTheme(name: DefaultTheme.DARK.themeName, base: DefaultTheme.DARK, colors: DarkColorPalette.clone(), appColors: DarkColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.DARK)))
case DefaultTheme.SIMPLEX.themeName: ActiveTheme(name: DefaultTheme.SIMPLEX.themeName, base: DefaultTheme.SIMPLEX, colors: SimplexColorPalette.clone(), appColors: SimplexColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.SIMPLEX)))
case DefaultTheme.BLACK.themeName: ActiveTheme(name: DefaultTheme.BLACK.themeName, base: DefaultTheme.BLACK, colors: BlackColorPalette.clone(), appColors: BlackColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.BLACK)))
default: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
}
let perUserTheme = baseTheme.colors.isLight ? perUserTheme?.light : perUserTheme?.dark
let theme = appSettingsTheme.sameTheme(themeOverridesForType ?? perChatTheme?.type ?? perUserTheme?.type ?? defaultTheme?.wallpaper?.toAppWallpaper().type, nonSystemThemeName) ?? defaultTheme
if theme == nil && perUserTheme == nil && perChatTheme == nil && themeOverridesForType == nil {
return ActiveTheme(name: themeName, base: baseTheme.base, colors: baseTheme.colors, appColors: baseTheme.appColors, wallpaper: baseTheme.wallpaper)
}
let presetWallpaperTheme: ThemeColors? = if let themeOverridesForType, case let WallpaperType.preset(filename, _) = themeOverridesForType {
PresetWallpaper.from(filename)?.colors[baseTheme.base]
} else if let wallpaper = perChatTheme?.wallpaper {
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
} else if let wallpaper = perUserTheme?.wallpaper {
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
} else {
if let preset = theme?.wallpaper?.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
}
let themeOrEmpty = theme ?? ThemeOverrides(base: baseTheme.base)
let colors = themeOrEmpty.toColors(themeOrEmpty.base, perChatTheme?.colors, perUserTheme?.colors, presetWallpaperTheme)
return ActiveTheme(
name: themeName,
base: baseTheme.base,
colors: colors,
appColors: themeOrEmpty.toAppColors(themeOrEmpty.base, perChatTheme?.colors, perChatTheme?.type, perUserTheme?.colors, perUserTheme?.type, presetWallpaperTheme),
wallpaper: themeOrEmpty.toAppWallpaper(themeOverridesForType, perChatTheme, perUserTheme, colors.background)
)
}
static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides {
let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get())
let wType = current.wallpaper.type
let wBackground = current.wallpaper.background
let wTint = current.wallpaper.tint
let w: ThemeWallpaper? = if case WallpaperType.empty = wType {
nil
} else {
ThemeWallpaper.from(wType, wBackground?.toReadableHex(), wTint?.toReadableHex()).withFilledWallpaperBase64()
}
return ThemeOverrides(
themeId: "",
base: current.base,
colors: ThemeColors.from(current.colors, current.appColors),
wallpaper: w
)
}
static func applyTheme(_ theme: String) {
currentThemeDefault.set(theme)
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
AppTheme.shared.updateFromCurrentColors()
let tint = UIColor(CurrentColors.colors.primary)
if SceneDelegate.windowStatic?.tintColor != tint {
SceneDelegate.windowStatic?.tintColor = tint
}
// applyNavigationBarColors(CurrentColors.toAppTheme())
}
static func adjustWindowStyle() {
let style = switch currentThemeDefault.get() {
case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light
case DefaultTheme.SYSTEM_THEME_NAME: UIUserInterfaceStyle.unspecified
default: UIUserInterfaceStyle.dark
}
if SceneDelegate.windowStatic?.overrideUserInterfaceStyle != style {
SceneDelegate.windowStatic?.overrideUserInterfaceStyle = style
}
}
// static func applyNavigationBarColors(_ theme: AppTheme) {
// let baseColors = switch theme.base {
// case DefaultTheme.LIGHT: LightColorPaletteApp
// case DefaultTheme.DARK: DarkColorPaletteApp
// case DefaultTheme.SIMPLEX: SimplexColorPaletteApp
// case DefaultTheme.BLACK: BlackColorPaletteApp
// }
// let isDefaultColor = baseColors.title == theme.appColors.title
//
// let title = UIColor(theme.appColors.title)
// if !isDefaultColor && UINavigationBar.appearance().titleTextAttributes?.first as? UIColor != title {
// UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: title]
// UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: title]
// } else {
// UINavigationBar.appearance().titleTextAttributes = nil
// UINavigationBar.appearance().largeTitleTextAttributes = nil
// }
// }
static func changeDarkTheme(_ theme: String) {
systemDarkThemeDefault.set(theme)
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
AppTheme.shared.updateFromCurrentColors()
}
static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let nonSystemThemeName = baseTheme.themeName
let pref = pref ?? themeOverridesDefault
let overrides = pref.get()
let themeId = currentThemeIdsDefault.get()[nonSystemThemeName]
let prevValue = overrides.getTheme(themeId) ?? ThemeOverrides(base: baseTheme)
pref.set(overrides.replace(prevValue.withUpdatedColor(name, color?.toReadableHex())))
var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds)
applyTheme(currentThemeDefault.get())
}
static func applyThemeColor(name: ThemeColor, color: Color? = nil, pref: Binding<ThemeModeOverride>) {
pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex())
}
static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) {
let nonSystemThemeName = baseTheme.themeName
let pref = pref ?? themeOverridesDefault
let overrides = pref.get()
let theme = overrides.sameTheme(type, baseTheme.themeName)
var prevValue = theme ?? ThemeOverrides(base: baseTheme)
prevValue.wallpaper = if let type {
if case WallpaperType.empty = type {
nil as ThemeWallpaper?
} else {
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
}
} else {
nil
}
pref.set(overrides.replace(prevValue))
var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds)
applyTheme(nonSystemThemeName)
}
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {
let overrides = themeOverridesDefault.get()
let sameWallpaper: ThemeWallpaper? = if let wallpaper = lowerLevelOverride?.wallpaper, lowerLevelOverride?.type?.sameType(type) == true {
wallpaper
} else {
overrides.sameTheme(type, CurrentColors.base.themeName)?.wallpaper
}
guard let sameWallpaper else {
if let type {
var w: ThemeWallpaper = ThemeWallpaper.from(type, nil, nil)
w.scale = nil
w.scaleType = nil
w.background = nil
w.tint = nil
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: w)
} else {
// Make an empty wallpaper to override any top level ones
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: ThemeWallpaper())
}
return true
}
var type = sameWallpaper.toAppWallpaper().type
if case let WallpaperType.image(filename, scale, scaleType) = type, sameWallpaper.imageFile == filename {
// same image file. Needs to be copied first in order to be able to remove the file once it's not needed anymore without affecting main theme override
if let filename = saveWallpaperFile(url: getWallpaperFilePath(filename)) {
type = WallpaperType.image(filename, scale, scaleType)
} else {
logger.error("Error while copying wallpaper from global overrides to chat overrides")
return false
}
}
var prevValue = pref.wrappedValue
var w = ThemeWallpaper.from(type, nil, nil)
w.scale = nil
w.scaleType = nil
w.background = nil
w.tint = nil
prevValue.colors = ThemeColors()
prevValue.wallpaper = w
pref.wrappedValue = prevValue
return true
}
static func applyWallpaper(_ type: WallpaperType?, _ pref: Binding<ThemeModeOverride>) {
var prevValue = pref.wrappedValue
prevValue.wallpaper = if let type {
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
} else {
nil
}
pref.wrappedValue = prevValue
}
static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let wallpaper = theme.wallpaper?.importFromString()
let nonSystemThemeName = theme.base.themeName
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
let overrides = pref.get()
var prevValue = overrides.getTheme(nil, wallpaper?.toAppWallpaper().type, theme.base) ?? ThemeOverrides(base: theme.base)
if let imageFile = prevValue.wallpaper?.imageFile {
try? FileManager.default.removeItem(at: getWallpaperFilePath(imageFile))
}
prevValue.base = theme.base
prevValue.colors = theme.colors
prevValue.wallpaper = wallpaper
pref.set(overrides.replace(prevValue))
currentThemeDefault.set(nonSystemThemeName)
var currentThemeIds = currentThemeIdsDefault.get()
currentThemeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(currentThemeIds)
applyTheme(nonSystemThemeName)
}
static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) {
let nonSystemThemeName = nonSystemThemeName()
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
let overrides = pref.get()
guard let themeId = currentThemeIdsDefault.get()[nonSystemThemeName],
var prevValue = overrides.getTheme(themeId)
else { return }
prevValue.colors = ThemeColors()
prevValue.wallpaper?.background = nil
prevValue.wallpaper?.tint = nil
pref.set(overrides.replace(prevValue))
applyTheme(currentThemeDefault.get())
}
static func resetAllThemeColors(_ pref: Binding<ThemeModeOverride>) {
var prevValue = pref.wrappedValue
prevValue.colors = ThemeColors()
prevValue.wallpaper?.background = nil
prevValue.wallpaper?.tint = nil
pref.wrappedValue = prevValue
}
static func removeTheme(_ themeId: String?) {
var themes = themeOverridesDefault.get().map { $0 }
themes.removeAll(where: { $0.themeId == themeId })
themeOverridesDefault.set(themes)
}
}
@@ -9,7 +9,6 @@
import SwiftUI import SwiftUI
import WebKit import WebKit
import SimpleXChat import SimpleXChat
import AVFoundation
struct ActiveCallView: View { struct ActiveCallView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@@ -22,7 +21,6 @@ struct ActiveCallView: View {
@Binding var canConnectCall: Bool @Binding var canConnectCall: Bool
@State var prevColorScheme: ColorScheme = .dark @State var prevColorScheme: ColorScheme = .dark
@State var pipShown = false @State var pipShown = false
@State var wasConnected = false
var body: some View { var body: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
@@ -71,11 +69,6 @@ struct ActiveCallView: View {
Task { await m.callCommand.setClient(nil) } Task { await m.callCommand.setClient(nil) }
AppDelegate.keepScreenOn(false) AppDelegate.keepScreenOn(false)
client?.endCall() client?.endCall()
CallSoundsPlayer.shared.stop()
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient)
if (wasConnected) {
CallSoundsPlayer.shared.vibrate(long: true)
}
} }
.background(m.activeCallViewIsCollapsed ? .clear : .black) .background(m.activeCallViewIsCollapsed ? .clear : .black)
// Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it // Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it
@@ -110,11 +103,6 @@ struct ActiveCallView: View {
call.callState = .invitationSent call.callState = .invitationSent
call.localCapabilities = capabilities call.localCapabilities = capabilities
} }
if call.supportsVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
}
CallSoundsPlayer.shared.startConnectingCallSound()
activeCallWaitDeliveryReceipt()
} }
case let .offer(offer, iceCandidates, capabilities): case let .offer(offer, iceCandidates, capabilities):
Task { Task {
@@ -138,7 +126,6 @@ struct ActiveCallView: View {
} }
await MainActor.run { await MainActor.run {
call.callState = .negotiated call.callState = .negotiated
CallSoundsPlayer.shared.stop()
} }
} }
case let .ice(iceCandidates): case let .ice(iceCandidates):
@@ -157,10 +144,6 @@ struct ActiveCallView: View {
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil) : CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
call.callState = .connected call.callState = .connected
call.connectedAt = .now call.connectedAt = .now
if !wasConnected {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
}
} }
if state.connectionState == "closed" { if state.connectionState == "closed" {
closeCallView(client) closeCallView(client)
@@ -178,10 +161,6 @@ struct ActiveCallView: View {
call.callState = .connected call.callState = .connected
call.connectionInfo = connectionInfo call.connectionInfo = connectionInfo
call.connectedAt = .now call.connectedAt = .now
if !wasConnected {
CallSoundsPlayer.shared.vibrate(long: false)
wasConnected = true
}
case .ended: case .ended:
closeCallView(client) closeCallView(client)
call.callState = .ended call.callState = .ended
@@ -208,22 +187,6 @@ struct ActiveCallView: View {
} }
} }
private func activeCallWaitDeliveryReceipt() {
ChatReceiver.shared.messagesChannel = { msg in
guard let call = ChatModel.shared.activeCall, call.callState == .invitationSent else {
ChatReceiver.shared.messagesChannel = nil
return
}
if case let .chatItemStatusUpdated(_, msg) = msg,
msg.chatInfo.id == call.contact.id,
case .sndCall = msg.chatItem.content,
case .sndRcvd = msg.chatItem.meta.itemStatus {
CallSoundsPlayer.shared.startInCallSound()
ChatReceiver.shared.messagesChannel = nil
}
}
}
private func closeCallView(_ client: WebRTCClient) { private func closeCallView(_ client: WebRTCClient) {
if m.activeCall != nil { if m.activeCall != nil {
m.showCallView = false m.showCallView = false
@@ -235,7 +198,6 @@ struct ActiveCallOverlay: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var call: Call @ObservedObject var call: Call
var client: WebRTCClient var client: WebRTCClient
@ObservedObject private var deviceManager = CallAudioDeviceManager.shared
var body: some View { var body: some View {
VStack { VStack {
@@ -251,16 +213,7 @@ struct ActiveCallOverlay: View {
HStack { HStack {
toggleAudioButton() toggleAudioButton()
Spacer() Spacer()
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) { Color.clear.frame(width: 40, height: 40)
toggleSpeakerButton()
.frame(width: 40, height: 40)
} else if call.hasMedia {
AudioDevicePicker()
.scaleEffect(2)
.frame(maxWidth: 40, maxHeight: 40)
} else {
Color.clear.frame(width: 40, height: 40)
}
Spacer() Spacer()
endCallButton() endCallButton()
Spacer() Spacer()
@@ -286,7 +239,9 @@ struct ActiveCallOverlay: View {
.foregroundColor(.white.opacity(0.8)) .foregroundColor(.white.opacity(0.8))
} }
VStack { VStack {
ProfileImage(imageStr: call.contact.profile.image, size: 192) ProfileImage(imageStr: call.contact.profile.image)
.scaledToFit()
.frame(width: 192, height: 192)
audioCallInfoView(call) audioCallInfoView(call)
} }
.foregroundColor(.white) .foregroundColor(.white)
@@ -301,33 +256,14 @@ struct ActiveCallOverlay: View {
toggleAudioButton() toggleAudioButton()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
endCallButton() endCallButton()
// Check if the only input is microphone. And in this case show toggle button, toggleSpeakerButton()
// If there are more inputs, it probably means something like bluetooth headphones are available .frame(maxWidth: .infinity, alignment: .trailing)
// and in this case show iOS button for choosing different output.
// There is no way to get available outputs, only inputs
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
toggleSpeakerButton()
.frame(maxWidth: .infinity, alignment: .trailing)
} else if call.hasMedia {
AudioDevicePicker()
.scaleEffect(2)
.frame(maxWidth: 50, maxHeight: 40)
.frame(maxWidth: .infinity, alignment: .trailing)
} else {
Color.clear.frame(width: 50, height: 40)
}
} }
.padding(.bottom, 60) .padding(.bottom, 60)
.padding(.horizontal, 48) .padding(.horizontal, 48)
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.onAppear {
deviceManager.start()
}
.onDisappear {
deviceManager.stop()
}
} }
private func audioCallInfoView(_ call: Call) -> some View { private func audioCallInfoView(_ call: Call) -> some View {
@@ -405,17 +341,12 @@ struct ActiveCallOverlay: View {
private func toggleSpeakerButton() -> some View { private func toggleSpeakerButton() -> some View {
controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") { controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
Task { Task {
let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker client.setSpeakerEnabledAndConfigureSession(!call.speakerEnabled)
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
DispatchQueue.main.async { DispatchQueue.main.async {
call.speakerEnabled = !speakerEnabled call.speakerEnabled = !call.speakerEnabled
} }
} }
} }
.onAppear {
deviceManager.call = call
//call.speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
}
} }
private func toggleVideoButton() -> some View { private func toggleVideoButton() -> some View {
@@ -1,25 +0,0 @@
//
// MPVolumeView.swift
// SimpleX (iOS)
//
// Created by Avently on 24.04.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import UIKit
import AVKit
struct AudioDevicePicker: UIViewRepresentable {
func makeUIView(context: Context) -> some UIView {
let v = AVRoutePickerView(frame: .zero)
v.activeTintColor = .white
v.tintColor = .white
return v
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}
@@ -1,67 +0,0 @@
//
// CallAudioDeviceManager.swift
// SimpleX (iOS)
//
// Created by Avently on 23.04.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
import SimpleXChat
import AVKit
import WebRTC
class CallAudioDeviceManager: ObservableObject {
static let shared = CallAudioDeviceManager()
let audioSession: AVAudioSession
let nc = NotificationCenter.default
var call: Call?
var timer: Timer? = nil
// Actually, only one output
@Published var outputs: [AVAudioSessionPortDescription]
@Published var currentDevice: AVAudioSessionPortDescription? = nil
// All devices that can record audio (the ones that can play audio are not included)
@Published var availableInputs: [AVAudioSessionPortDescription] = []
init(_ audioSession: AVAudioSession? = nil) {
self.audioSession = audioSession ?? RTCAudioSession.sharedInstance().session
self.outputs = self.audioSession.currentRoute.outputs
self.availableInputs = self.audioSession.availableInputs ?? []
}
func reloadDevices() {
outputs = audioSession.currentRoute.outputs
currentDevice = audioSession.currentRoute.outputs.first
availableInputs = audioSession.availableInputs ?? []
call?.speakerEnabled = currentDevice?.portType == .builtInSpeaker
// Workaround situation:
// have bluetooth device connected, choosing speaker, disconnecting bluetooth device. In this case iOS will not post notification, so do it manually
timer?.invalidate()
if availableInputs.contains(where: { $0.portType != .builtInReceiver && $0.portType != .builtInSpeaker }) {
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { t in
self.reloadDevices()
}
}
}
@objc func audioCallback(notification: Notification) {
reloadDevices()
logger.debug("Changes in devices, current audio devices: \(String(describing: self.availableInputs.map({ $0.portType.rawValue }))), output: \(String(describing: self.currentDevice?.portType.rawValue))")
}
func start() {
nc.addObserver(self, selector: #selector(audioCallback), name: AVAudioSession.routeChangeNotification, object: nil)
}
func stop() {
nc.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil)
timer?.invalidate()
}
}
@@ -103,23 +103,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
RTCAudioSession.sharedInstance().isAudioEnabled = true RTCAudioSession.sharedInstance().isAudioEnabled = true
do { do {
let supportsVideo = ChatModel.shared.activeCall?.supportsVideo == true try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers)
if supportsVideo {
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
} else {
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
}
// Without any delay sound is not playing from speaker or external device in incoming call
Task {
for i in 0 ... 3 {
try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000)
if let preferred = audioSession.preferredInputDevice() {
await MainActor.run { try? audioSession.setPreferredInput(preferred) }
} else if supportsVideo {
await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
}
}
}
logger.debug("audioSession category set") logger.debug("audioSession category set")
try audioSession.setActive(true) try audioSession.setActive(true)
logger.debug("audioSession activated") logger.debug("audioSession activated")
@@ -11,7 +11,6 @@ import SimpleXChat
struct IncomingCallView: View { struct IncomingCallView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var cc = CallController.shared @ObservedObject var cc = CallController.shared
var body: some View { var body: some View {
@@ -31,7 +30,8 @@ struct IncomingCallView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack { HStack {
if m.users.count > 1 { if m.users.count > 1 {
ProfileImage(imageStr: invitation.user.image, size: 24, color: .white) ProfileImage(imageStr: invitation.user.image, color: .white)
.frame(width: 24, height: 24)
} }
Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green) Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green)
Text(invitation.callTypeText) Text(invitation.callTypeText)
@@ -44,7 +44,7 @@ struct IncomingCallView: View {
cc.endCall(invitation: invitation) cc.endCall(invitation: invitation)
} }
callButton("Ignore", "multiply", .primary) { callButton("Ignore", "multiply", .accentColor) {
cc.activeCallInvitation = nil cc.activeCallInvitation = nil
} }
@@ -64,7 +64,7 @@ struct IncomingCallView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.modifier(ThemedBackground()) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.onAppear { dismissAllSheets() } .onAppear { dismissAllSheets() }
} }
@@ -77,7 +77,7 @@ struct IncomingCallView: View {
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
Text(text) Text(text)
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
.frame(minWidth: 44) .frame(minWidth: 44)
}) })
@@ -8,7 +8,6 @@
import Foundation import Foundation
import AVFoundation import AVFoundation
import UIKit
class SoundPlayer { class SoundPlayer {
static let shared = SoundPlayer() static let shared = SoundPlayer()
@@ -44,63 +43,3 @@ class SoundPlayer {
audioPlayer = nil audioPlayer = nil
} }
} }
class CallSoundsPlayer {
static let shared = CallSoundsPlayer()
private var audioPlayer: AVAudioPlayer?
private var playerTask: Task = Task {}
private func start(_ soundName: String, delayMs: Double) {
audioPlayer?.stop()
playerTask.cancel()
logger.debug("start \(soundName)")
guard let path = Bundle.main.path(forResource: soundName, ofType: "mp3", inDirectory: "sounds") else {
logger.debug("start: file not found")
return
}
do {
let player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
if player.prepareToPlay() {
audioPlayer = player
}
} catch {
logger.debug("start: AVAudioPlayer error \(error.localizedDescription)")
}
playerTask = Task {
while let player = audioPlayer {
player.play()
do {
try await Task.sleep(nanoseconds: UInt64((player.duration * 1_000_000_000) + delayMs * 1_000_000))
} catch {
break
}
}
}
}
func startConnectingCallSound() {
start("connecting_call", delayMs: 0)
}
func startInCallSound() {
// Taken from https://github.com/TelegramOrg/Telegram-Android
// https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE
start("in_call", delayMs: 1000)
}
func stop() {
playerTask.cancel()
audioPlayer?.stop()
audioPlayer = nil
}
func vibrate(long: Bool) {
// iOS just don't want to vibrate more than once after a short period of time, and all 'styles' feel the same
if long {
AudioServicesPlayAlertSound(kSystemSoundID_Vibrate)
} else {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
}
}
}
+3 -4
View File
@@ -431,18 +431,17 @@ struct RTCIceServer: Codable, Equatable {
} }
// the servers are expected in this format: // the servers are expected in this format:
// stuns:stun.simplex.im:443?transport=tcp // stun:stun.simplex.im:443?transport=tcp
// turns:private2:Hxuq2QxUjnhj96Zq2r4HjqHRj@turn.simplex.im:443?transport=tcp // turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp
func parseRTCIceServer(_ str: String) -> RTCIceServer? { func parseRTCIceServer(_ str: String) -> RTCIceServer? {
var s = replaceScheme(str, "stun:") var s = replaceScheme(str, "stun:")
s = replaceScheme(s, "stuns:")
s = replaceScheme(s, "turn:") s = replaceScheme(s, "turn:")
s = replaceScheme(s, "turns:") s = replaceScheme(s, "turns:")
if let u: URL = URL(string: s), if let u: URL = URL(string: s),
let scheme = u.scheme, let scheme = u.scheme,
let host = u.host, let host = u.host,
let port = u.port, let port = u.port,
u.path == "" && (scheme == "stun" || scheme == "stuns" || scheme == "turn" || scheme == "turns") { u.path == "" && (scheme == "stun" || scheme == "turn" || scheme == "turns") {
let query = u.query == nil || u.query == "" ? "" : "?" + (u.query ?? "") let query = u.query == nil || u.query == "" ? "" : "?" + (u.query ?? "")
return RTCIceServer( return RTCIceServer(
urls: ["\(scheme):\(host):\(port)\(query)"], urls: ["\(scheme):\(host):\(port)\(query)"],
+8 -33
View File
@@ -49,7 +49,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
private let rtcAudioSession = RTCAudioSession.sharedInstance() private let rtcAudioSession = RTCAudioSession.sharedInstance()
private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio") private let audioQueue = DispatchQueue(label: "audio")
private var sendCallResponse: (WVAPIMessage) async -> Void private var sendCallResponse: (WVAPIMessage) async -> Void
var activeCall: Binding<Call?> var activeCall: Binding<Call?>
private var localRendererAspectRatio: Binding<CGFloat?> private var localRendererAspectRatio: Binding<CGFloat?>
@@ -65,14 +65,14 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
self.localRendererAspectRatio = localRendererAspectRatio self.localRendererAspectRatio = localRendererAspectRatio
rtcAudioSession.useManualAudio = CallController.useCallKit() rtcAudioSession.useManualAudio = CallController.useCallKit()
rtcAudioSession.isAudioEnabled = !CallController.useCallKit() rtcAudioSession.isAudioEnabled = !CallController.useCallKit()
logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)") logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)}")
super.init() super.init()
} }
let defaultIceServers: [WebRTC.RTCIceServer] = [ let defaultIceServers: [WebRTC.RTCIceServer] = [
WebRTC.RTCIceServer(urlStrings: ["stuns:stun.simplex.im:443"]), WebRTC.RTCIceServer(urlStrings: ["stun:stun.simplex.im:443"]),
//WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=udp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"), WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"), WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
] ]
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
@@ -605,23 +605,9 @@ extension WebRTCClient {
self.rtcAudioSession.unlockForConfiguration() self.rtcAudioSession.unlockForConfiguration()
} }
do { do {
let hasExternalAudioDevice = self.rtcAudioSession.session.hasExternalAudioDevice() try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue)
if enabled { try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) try self.rtcAudioSession.overrideOutputAudioPort(enabled ? .speaker : .none)
try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue)
if hasExternalAudioDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
try self.rtcAudioSession.setPreferredInput(preferred)
} else {
try self.rtcAudioSession.overrideOutputAudioPort(.speaker)
}
} else {
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
try self.rtcAudioSession.overrideOutputAudioPort(.none)
}
if hasExternalAudioDevice {
logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker")
}
try self.rtcAudioSession.setActive(true) try self.rtcAudioSession.setActive(true)
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success") logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success")
} catch let error { } catch let error {
@@ -672,17 +658,6 @@ extension WebRTCClient {
} }
} }
extension AVAudioSession {
func hasExternalAudioDevice() -> Bool {
availableInputs?.allSatisfy({ $0.portType == .builtInMic }) != true
}
func preferredInputDevice() -> AVAudioSessionPortDescription? {
// logger.debug("Preferred input device: \(String(describing: self.availableInputs?.filter({ $0.portType != .builtInMic })))")
return availableInputs?.filter({ $0.portType != .builtInMic }).last
}
}
struct CustomRTCSessionDescription: Codable { struct CustomRTCSessionDescription: Codable {
public var type: RTCSdpType? public var type: RTCSdpType?
public var sdp: String? public var sdp: String?
@@ -9,9 +9,10 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2)
struct ChatInfoToolbar: View { struct ChatInfoToolbar: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var imageSize: CGFloat = 32 var imageSize: CGFloat = 32
@@ -24,30 +25,30 @@ struct ChatInfoToolbar: View {
} }
ChatInfoImage( ChatInfoImage(
chat: chat, chat: chat,
size: imageSize, color: colorScheme == .dark
color: Color(uiColor: .tertiaryLabel) ? chatImageColorDark
: chatImageColorLight
) )
.frame(width: imageSize, height: imageSize)
.padding(.trailing, 4) .padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline) VStack {
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t) let t = Text(cInfo.displayName).font(.headline)
.lineLimit(1) (cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in .lineLimit(1)
VStack(spacing: 0) { if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
v Text(cInfo.fullName).font(.subheadline)
Text(cInfo.fullName).font(.subheadline) .lineLimit(1)
.lineLimit(1)
.padding(.top, -2)
}
} }
}
} }
.foregroundColor(theme.colors.onBackground) .foregroundColor(.primary)
.frame(width: 220) .frame(width: 220)
} }
private var contactVerifiedShield: Text { private var contactVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" ")) (Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.baselineOffset(1) .baselineOffset(1)
.kerning(-2) .kerning(-2)
} }
@@ -56,6 +57,5 @@ struct ChatInfoToolbar: View {
struct ChatInfoToolbar_Previews: PreviewProvider { struct ChatInfoToolbar_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
.environmentObject(CurrentColors.toAppTheme())
} }
} }
+122 -651
View File
@@ -36,20 +36,20 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey)
} }
} }
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String], _ secondaryColor: Color) -> some View { @ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]) -> some View {
if servers.count > 0 { if servers.count > 0 {
HStack { HStack {
Text(title).frame(width: 120, alignment: .leading) Text(title).frame(width: 120, alignment: .leading)
Button(serverHost(servers[0])) { Button(serverHost(servers[0])) {
UIPasteboard.general.string = servers.joined(separator: ";") UIPasteboard.general.string = servers.joined(separator: ";")
} }
.foregroundColor(secondaryColor) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
} }
} }
} }
func serverHost(_ s: String) -> String { private func serverHost(_ s: String) -> String {
if let i = s.range(of: "@")?.lowerBound { if let i = s.range(of: "@")?.lowerBound {
return String(s[i...].dropFirst()) return String(s[i...].dropFirst())
} else { } else {
@@ -90,24 +90,20 @@ enum SendReceipts: Identifiable, Hashable {
struct ChatInfoView: View { struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var networkModel = NetworkModel.shared
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@State var contact: Contact @State var contact: Contact
@Binding var connectionStats: ConnectionStats?
@Binding var customUserProfile: Profile?
@State var localAlias: String @State var localAlias: String
var onSearch: () -> Void @Binding var connectionCode: String?
@State private var connectionStats: ConnectionStats? = nil
@State private var customUserProfile: Profile? = nil
@State private var connectionCode: String? = nil
@FocusState private var aliasTextFieldFocused: Bool @FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil @State private var alert: ChatInfoViewAlert? = nil
@State private var actionSheet: SomeActionSheet? = nil @State private var showDeleteContactActionSheet = false
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true @State private var sendReceiptsUserDefault = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED, store: groupDefaults) private var pqExperimentalEnabled = false
enum ChatInfoViewAlert: Identifiable { enum ChatInfoViewAlert: Identifiable {
case clearChatAlert case clearChatAlert
@@ -115,9 +111,8 @@ struct ChatInfoView: View {
case switchAddressAlert case switchAddressAlert
case abortSwitchAddressAlert case abortSwitchAddressAlert
case syncConnectionForceAlert case syncConnectionForceAlert
case queueInfo(info: String) case allowContactPQEncryptionAlert
case someAlert(alert: SomeAlert) case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String { var id: String {
switch self { switch self {
@@ -126,8 +121,7 @@ struct ChatInfoView: View {
case .switchAddressAlert: return "switchAddressAlert" case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case let .queueInfo(info): return "queueInfo \(info)" case .allowContactPQEncryptionAlert: return "allowContactPQEncryptionAlert"
case let .someAlert(alert): return "chatInfoSomeAlert \(alert.id)"
case let .error(title, _): return "error \(title)" case let .error(title, _): return "error \(title)"
} }
} }
@@ -148,26 +142,9 @@ struct ChatInfoView: View {
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.padding(.bottom, 18)
GeometryReader { g in
HStack(alignment: .center, spacing: 8) {
let buttonWidth = g.size.width / 4
searchButton(width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
muteButton(width: buttonWidth)
}
}
.padding(.trailing)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
if let customUserProfile = customUserProfile { if let customUserProfile = customUserProfile {
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { Section("Incognito") {
HStack { HStack {
Text("Your random profile") Text("Your random profile")
Spacer() Spacer()
@@ -178,33 +155,32 @@ struct ChatInfoView: View {
} }
Section { Section {
Group { if let code = connectionCode { verifyCodeButton(code) }
if let code = connectionCode { verifyCodeButton(code) } contactPreferencesButton()
contactPreferencesButton() sendReceiptsOption()
sendReceiptsOption() if let connStats = connectionStats,
if let connStats = connectionStats, connStats.ratchetSyncAllowed {
connStats.ratchetSyncAllowed { synchronizeConnectionButton()
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
.disabled(!contact.ready || !contact.active) // } else if developerTools {
NavigationLink { // synchronizeConnectionButtonForce()
ChatWallpaperEditorSheet(chat: chat) // }
} label: {
Label("Chat theme", systemImage: "photo")
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
.disabled(!contact.ready || !contact.active) .disabled(!contact.ready || !contact.active)
if let conn = contact.activeConn { if pqExperimentalEnabled,
let conn = contact.activeConn {
Section { Section {
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
if !conn.pqEncryption {
allowPQButton()
}
} header: {
Text(String("Quantum resistant E2E encryption"))
} footer: {
if !conn.pqEncryption {
Text(String("After allowing quantum resistant encryption, it will be enabled after several messages if your contact also allows it."))
}
} }
} }
@@ -218,15 +194,13 @@ struct ChatInfoView: View {
} }
} header: { } header: {
Text("Address") Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: { } footer: {
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.") Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
.foregroundColor(theme.colors.secondary)
} }
} }
if contact.ready && contact.active { if contact.ready && contact.active {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { Section("Servers") {
networkStatusRow() networkStatusRow()
.onTapGesture { .onTapGesture {
alert = .networkStatusAlert alert = .networkStatusAlert
@@ -248,8 +222,8 @@ struct ChatInfoView: View {
|| connStats.ratchetSyncSendProhibited || connStats.ratchetSyncSendProhibited
) )
} }
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary) smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary) smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
} }
} }
} }
@@ -260,25 +234,12 @@ struct ChatInfoView: View {
} }
if developerTools { if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { Section(header: Text("For console")) {
infoRow("Local name", chat.chatInfo.localDisplayName) infoRow("Local name", chat.chatInfo.localDisplayName)
infoRow("Database ID", "\(chat.chatInfo.apiId)") infoRow("Database ID", "\(chat.chatInfo.apiId)")
Button ("Debug delivery") {
Task {
do {
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
await MainActor.run { alert = .queueInfo(info: info) }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
}
}
} }
} }
} }
.modifier(ThemedBackground(grouped: true))
.navigationBarHidden(true) .navigationBarHidden(true)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -287,24 +248,6 @@ struct ChatInfoView: View {
sendReceiptsUserDefault = currentUser.sendRcptsContacts sendReceiptsUserDefault = currentUser.sendRcptsContacts
} }
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
Task {
do {
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
await MainActor.run {
connectionStats = stats
customUserProfile = profile
connectionCode = code
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
chat.chatInfo = .direct(contact: ct)
}
}
} catch let error {
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
}
}
} }
.alert(item: $alert) { alertItem in .alert(item: $alert) { alertItem in
switch(alertItem) { switch(alertItem) {
@@ -313,31 +256,43 @@ struct ChatInfoView: View {
case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) }) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
case let .queueInfo(info): return queueInfoAlert(info) case .allowContactPQEncryptionAlert: return allowContactPQEncryptionAlert()
case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return mkAlert(title: title, message: error)
} }
} }
.actionSheet(item: $actionSheet) { $0.actionSheet } .actionSheet(isPresented: $showDeleteContactActionSheet) {
.sheet(item: $sheet) { if contact.ready && contact.active {
if #available(iOS 16.0, *) { return ActionSheet(
$0.content title: Text("Delete contact?\nThis cannot be undone!"),
.presentationDetents([.fraction(0.4)]) buttons: [
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
.destructive(Text("Delete")) { deleteContact(notify: false) },
.cancel()
]
)
} else { } else {
$0.content return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { deleteContact() },
.cancel()
]
)
} }
} }
} }
private func contactInfoHeader() -> some View { private func contactInfoHeader() -> some View {
VStack(spacing: 8) { VStack {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill)) ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
.padding(.vertical, 12) .frame(width: 192, height: 192)
.padding(.top, 12)
.padding()
if contact.verified { if contact.verified {
( (
Text(Image(systemName: "checkmark.shield")) Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.font(.title2) .font(.title2)
+ Text(" ") + Text(" ")
+ Text(contact.profile.displayName) + Text(contact.profile.displayName)
@@ -377,7 +332,7 @@ struct ChatInfoView: View {
setContactAlias() setContactAlias()
} }
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
private func setContactAlias() { private func setContactAlias() {
@@ -394,25 +349,6 @@ struct ChatInfoView: View {
} }
} }
private func searchButton(width: CGFloat) -> some View {
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
dismiss()
onSearch()
}
.disabled(!contact.ready || chat.chatItems.isEmpty)
}
private func muteButton(width: CGFloat) -> some View {
InfoViewButton(
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
width: width
) {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
}
.disabled(!contact.ready || !contact.active)
}
private func verifyCodeButton(_ code: String) -> some View { private func verifyCodeButton(_ code: String) -> some View {
NavigationLink { NavigationLink {
VerifyCodeView( VerifyCodeView(
@@ -434,7 +370,6 @@ struct ChatInfoView: View {
) )
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code") .navigationTitle("Security code")
.modifier(ThemedBackground(grouped: true))
} label: { } label: {
Label( Label(
contact.verified ? "View security code" : "Verify security code", contact.verified ? "View security code" : "Verify security code",
@@ -451,7 +386,6 @@ struct ChatInfoView: View {
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences) currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
) )
.navigationBarTitle("Contact preferences") .navigationBarTitle("Contact preferences")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} label: { } label: {
Label("Contact preferences", systemImage: "switch.2") Label("Contact preferences", systemImage: "switch.2")
@@ -496,38 +430,40 @@ struct ChatInfoView: View {
} }
} }
private func allowPQButton() -> some View {
Button {
alert = .allowContactPQEncryptionAlert
} label: {
Label(String("Allow PQ encryption"), systemImage: "exclamationmark.triangle")
.foregroundColor(.orange)
}
}
private func networkStatusRow() -> some View { private func networkStatusRow() -> some View {
HStack { HStack {
Text("Network status") Text("Network status")
Image(systemName: "info.circle") Image(systemName: "info.circle")
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
.font(.system(size: 14)) .font(.system(size: 14))
Spacer() Spacer()
Text(networkModel.contactNetworkStatus(contact).statusString) Text(chatModel.contactNetworkStatus(contact).statusString)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
serverImage() serverImage()
} }
} }
private func serverImage() -> some View { private func serverImage() -> some View {
let status = networkModel.contactNetworkStatus(contact) let status = chatModel.contactNetworkStatus(contact)
return Image(systemName: status.imageName) return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : theme.colors.secondary) .foregroundColor(status == .connected ? .green : .secondary)
.font(.system(size: 12)) .font(.system(size: 12))
} }
private func deleteContactButton() -> some View { private func deleteContactButton() -> some View {
Button(role: .destructive) { Button(role: .destructive) {
deleteContactDialog( showDeleteContactActionSheet = true
chat,
contact,
dismissToChatList: true,
showAlert: { alert = .someAlert(alert: $0) },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: { } label: {
Label("Delete contact", systemImage: "person.badge.minus") Label("Delete contact", systemImage: "trash")
.foregroundColor(Color.red) .foregroundColor(Color.red)
} }
} }
@@ -541,6 +477,25 @@ struct ChatInfoView: View {
} }
} }
private func deleteContact(notify: Bool? = nil) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
await MainActor.run {
dismiss()
chatModel.chatId = nil
chatModel.removeChat(chat.chatInfo.id)
}
} catch let error {
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
let a = getErrorAlert(error, "Error deleting contact")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
}
}
}
}
private func clearChatAlert() -> Alert { private func clearChatAlert() -> Alert {
Alert( Alert(
title: Text("Clear conversation?"), title: Text("Clear conversation?"),
@@ -558,7 +513,7 @@ struct ChatInfoView: View {
private func networkStatusAlert() -> Alert { private func networkStatusAlert() -> Alert {
Alert( Alert(
title: Text("Network status"), title: Text("Network status"),
message: Text(networkModel.contactNetworkStatus(contact).statusExplanation) message: Text(chatModel.contactNetworkStatus(contact).statusExplanation)
) )
} }
@@ -617,295 +572,34 @@ struct ChatInfoView: View {
} }
} }
} }
}
struct AudioCallButton: View { private func allowContactPQEncryption() {
var chat: Chat Task {
var contact: Contact
var width: CGFloat
var showAlert: (SomeAlert) -> Void
var body: some View {
CallButton(
chat: chat,
contact: contact,
image: "phone.fill",
title: "call",
mediaType: .audio,
width: width,
showAlert: showAlert
)
}
}
struct VideoButton: View {
var chat: Chat
var contact: Contact
var width: CGFloat
var showAlert: (SomeAlert) -> Void
var body: some View {
CallButton(
chat: chat,
contact: contact,
image: "video.fill",
title: "video",
mediaType: .video,
width: width,
showAlert: showAlert
)
}
}
private struct CallButton: View {
var chat: Chat
var contact: Contact
var image: String
var title: LocalizedStringKey
var mediaType: CallMediaType
var width: CGFloat
var showAlert: (SomeAlert) -> Void
var body: some View {
let canCall = contact.ready && contact.active && chat.chatInfo.featureEnabled(.calls) && ChatModel.shared.activeCall == nil
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
if canCall {
CallController.shared.startCall(contact, mediaType)
} else if contact.nextSendGrpInv {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Send message to enable calls."
),
id: "can't call contact, send message"
))
} else if !contact.active {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Contact is deleted."
),
id: "can't call contact, contact deleted"
))
} else if !contact.ready {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Connecting to contact, please wait or check later!"
),
id: "can't call contact, contact not ready"
))
} else if !chat.chatInfo.featureEnabled(.calls) {
switch chat.chatInfo.showEnableCallsAlert {
case .userEnable:
showAlert(SomeAlert(
alert: Alert(
title: Text("Allow calls?"),
message: Text("You need to allow your contact to call to be able to call them."),
primaryButton: .default(Text("Allow")) {
allowFeatureToContact(contact, .calls)
},
secondaryButton: .cancel()
),
id: "allow calls"
))
case .askContact:
showAlert(SomeAlert(
alert: mkAlert(
title: "Calls prohibited!",
message: "Please ask your contact to enable calls."
),
id: "calls prohibited, ask contact"
))
case .other:
showAlert(SomeAlert(
alert: mkAlert(
title: "Calls prohibited!",
message: "Please check yours and your contact preferences."
)
, id: "calls prohibited, other"
))
}
} else {
showAlert(SomeAlert(
alert: mkAlert(title: "Can't call contact"),
id: "can't call contact"
))
}
}
.disabled(ChatModel.shared.activeCall != nil)
}
}
let infoViewActionButtonHeight: CGFloat = 60
struct InfoViewButton: View {
var image: String
var title: LocalizedStringKey
var disabledLook: Bool = false
var width: CGFloat
var action: () -> Void
var body: some View {
VStack(spacing: 4) {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
Text(title)
.font(.caption)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(.accentColor)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(10.0)
.frame(width: width, height: infoViewActionButtonHeight)
.disabled(disabledLook)
.onTapGesture(perform: action)
}
}
struct ChatWallpaperEditorSheet: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@State private var globalThemeUsed: Bool = false
@State var chat: Chat
@State private var themes: ThemeModeOverrides
init(chat: Chat) {
self.chat = chat
self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
uiThemes
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
}
}
var body: some View {
let preferred = themes.preferredMode(!theme.colors.isLight)
let initialTheme = preferred ?? ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
ChatWallpaperEditor(
initialTheme: initialTheme,
themeModeOverride: initialTheme,
applyToMode: themes.light == themes.dark ? nil : initialTheme.mode,
globalThemeUsed: $globalThemeUsed,
save: { applyToMode, newTheme in
await save(applyToMode, newTheme, $chat)
}
)
.navigationTitle("Chat theme")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
globalThemeUsed = preferred == nil
}
.onChange(of: theme.base.mode) { _ in
globalThemeUsed = themesFromChat(chat).preferredMode(!theme.colors.isLight) == nil
}
.onChange(of: ChatModel.shared.chatId) { _ in
dismiss()
}
}
private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides {
if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
uiThemes
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
uiThemes
} else {
ThemeModeOverrides()
}
}
private static var updateBackendTask: Task = Task {}
private func save(
_ applyToMode: DefaultThemeMode?,
_ newTheme: ThemeModeOverride?,
_ chat: Binding<Chat>
) async {
let unchangedThemes: ThemeModeOverrides = themesFromChat(chat.wrappedValue)
var wallpaperFiles = Set([unchangedThemes.light?.wallpaper?.imageFile, unchangedThemes.dark?.wallpaper?.imageFile])
var changedThemes: ThemeModeOverrides? = unchangedThemes
let light: ThemeModeOverride? = if let newTheme {
ThemeModeOverride(mode: DefaultThemeMode.light, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
} else {
nil
}
let dark: ThemeModeOverride? = if let newTheme {
ThemeModeOverride(mode: DefaultThemeMode.dark, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
} else {
nil
}
if let applyToMode {
switch applyToMode {
case DefaultThemeMode.light:
changedThemes?.light = light
case DefaultThemeMode.dark:
changedThemes?.dark = dark
}
} else {
changedThemes?.light = light
changedThemes?.dark = dark
}
if changedThemes?.light != nil || changedThemes?.dark != nil {
let light = changedThemes?.light
let dark = changedThemes?.dark
let currentMode = CurrentColors.base.mode
// same image file for both modes, copy image to make them as different files
if var light, var dark, let lightWallpaper = light.wallpaper, let darkWallpaper = dark.wallpaper, let lightImageFile = lightWallpaper.imageFile, let darkImageFile = darkWallpaper.imageFile, lightWallpaper.imageFile == darkWallpaper.imageFile {
let imageFile = if currentMode == DefaultThemeMode.light {
darkImageFile
} else {
lightImageFile
}
let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile))
if currentMode == DefaultThemeMode.light {
dark.wallpaper?.imageFile = filePath
changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark)
} else {
light.wallpaper?.imageFile = filePath
changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark)
}
}
} else {
changedThemes = nil
}
wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile)
wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile)
wallpaperFiles.forEach(removeWallpaperFile)
let changedThemesConstant = changedThemes
ChatWallpaperEditorSheet.updateBackendTask.cancel()
ChatWallpaperEditorSheet.updateBackendTask = Task {
do { do {
try await Task.sleep(nanoseconds: 300_000000) let ct = try await apiSetContactPQ(contact.apiId, true)
if await apiSetChatUIThemes(chatId: chat.id, themes: changedThemesConstant) { contact = ct
if case var ChatInfo.direct(contact) = chat.wrappedValue.chatInfo { await MainActor.run {
contact.uiThemes = changedThemesConstant chatModel.updateContact(ct)
await MainActor.run { dismiss()
ChatModel.shared.updateChatInfo(ChatInfo.direct(contact: contact)) }
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact)) } catch let error {
themes = themesFromChat(chat.wrappedValue) logger.error("allowContactPQEncryption apiSetContactPQ error: \(responseError(error))")
} let a = getErrorAlert(error, "Error allowing contact PQ encryption")
} else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo { await MainActor.run {
groupInfo.uiThemes = changedThemesConstant alert = .error(title: a.title, error: a.message)
await MainActor.run {
ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo))
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo))
themes = themesFromChat(chat.wrappedValue)
}
}
} }
} catch {
// canceled task
} }
} }
} }
func allowContactPQEncryptionAlert() -> Alert {
Alert(
title: Text(String("Allow quantum resistant encryption?")),
message: Text(String("This is an experimental feature, it is not recommended to enable it for important chats.")),
primaryButton: .destructive(Text(String("Allow")), action: allowContactPQEncryption),
secondaryButton: .cancel()
)
}
} }
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert { func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
@@ -935,238 +629,15 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
) )
} }
func queueInfoText(_ info: (RcvMsgInfo?, QueueInfo)) -> String {
let (rcvMsgInfo, qInfo) = info
var msgInfo: String
if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" }
return String.localizedStringWithFormat(NSLocalizedString("server queue info: %@\n\nlast received msg: %@", comment: "queue info"), encodeJSON(qInfo), msgInfo)
}
func queueInfoAlert(_ info: String) -> Alert {
Alert(
title: Text("Message queue info"),
message: Text(info),
primaryButton: .default(Text("Ok")),
secondaryButton: .default(Text("Copy")) { UIPasteboard.general.string = info }
)
}
func deleteContactDialog(
_ chat: Chat,
_ contact: Contact,
dismissToChatList: Bool,
showAlert: @escaping (SomeAlert) -> Void,
showActionSheet: @escaping (SomeActionSheet) -> Void,
showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
) {
if contact.sndReady && contact.active && !contact.chatDeleted {
deleteContactOrConversationDialog(chat, contact, dismissToChatList, showAlert, showActionSheet, showSheetContent)
} else if contact.sndReady && contact.active && contact.chatDeleted {
deleteContactWithoutConversation(chat, contact, dismissToChatList, showAlert, showActionSheet)
} else { // !(contact.sndReady && contact.active)
deleteNotReadyContact(chat, contact, dismissToChatList, showAlert, showActionSheet)
}
}
private func deleteContactOrConversationDialog(
_ chat: Chat,
_ contact: Contact,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void,
_ showActionSheet: @escaping (SomeActionSheet) -> Void,
_ showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
) {
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Delete contact?"),
buttons: [
.destructive(Text("Only delete conversation")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert)
},
.destructive(Text("Delete contact")) {
showSheetContent(SomeSheet(
content: { AnyView(
DeleteActiveContactDialog(
chat: chat,
contact: contact,
dismissToChatList: dismissToChatList,
showAlert: showAlert
)
) },
id: "DeleteActiveContactDialog"
))
},
.cancel()
]
),
id: "deleteContactOrConversationDialog"
))
}
private func deleteContactMaybeErrorAlert(
_ chat: Chat,
_ contact: Contact,
chatDeleteMode: ChatDeleteMode,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void
) {
Task {
let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode)
if let alert = alert_ {
showAlert(SomeAlert(alert: alert, id: "deleteContactMaybeErrorAlert, error"))
} else {
if dismissToChatList {
await MainActor.run {
ChatModel.shared.chatId = nil
}
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
AlertManager.shared.showAlert(deleteConversationNotice(contact))
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
}
} else {
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
showAlert(SomeAlert(alert: deleteConversationNotice(contact), id: "deleteContactMaybeErrorAlert, deleteConversationNotice"))
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
showAlert(SomeAlert(alert: deleteContactNotice(contact), id: "deleteContactMaybeErrorAlert, deleteContactNotice"))
}
}
}
}
}
private func deleteConversationNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Conversation deleted!"),
message: Text("You can send messages to \(contact.displayName) from Archived contacts."),
primaryButton: .default(Text("Don't show again")) {
showDeleteConversationNoticeDefault.set(false)
},
secondaryButton: .default(Text("Ok"))
)
}
private func deleteContactNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Contact deleted!"),
message: Text("You can still view conversation with \(contact.displayName) in the list of chats."),
primaryButton: .default(Text("Don't show again")) {
showDeleteContactNoticeDefault.set(false)
},
secondaryButton: .default(Text("Ok"))
)
}
enum ContactDeleteMode {
case full
case entity
public func toChatDeleteMode(notify: Bool) -> ChatDeleteMode {
switch self {
case .full: .full(notify: notify)
case .entity: .entity(notify: notify)
}
}
}
struct DeleteActiveContactDialog: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
var chat: Chat
var contact: Contact
var dismissToChatList: Bool
var showAlert: (SomeAlert) -> Void
@State private var keepConversation = false
var body: some View {
NavigationView {
List {
Section {
Toggle("Keep conversation", isOn: $keepConversation)
Button(role: .destructive) {
dismiss()
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), dismissToChatList, showAlert)
} label: {
Text("Delete without notification")
}
Button(role: .destructive) {
dismiss()
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), dismissToChatList, showAlert)
} label: {
Text("Delete and notify contact")
}
} footer: {
Text("Contact will be deleted - this cannot be undone!")
.foregroundColor(theme.colors.secondary)
}
}
.modifier(ThemedBackground(grouped: true))
}
}
var contactDeleteMode: ContactDeleteMode {
keepConversation ? .entity : .full
}
}
private func deleteContactWithoutConversation(
_ chat: Chat,
_ contact: Contact,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void,
_ showActionSheet: @escaping (SomeActionSheet) -> Void
) {
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Confirm contact deletion?"),
buttons: [
.destructive(Text("Delete and notify contact")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: true), dismissToChatList, showAlert)
},
.destructive(Text("Delete without notification")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
},
.cancel()
]
),
id: "deleteContactWithoutConversation"
))
}
private func deleteNotReadyContact(
_ chat: Chat,
_ contact: Contact,
_ dismissToChatList: Bool,
_ showAlert: @escaping (SomeAlert) -> Void,
_ showActionSheet: @escaping (SomeActionSheet) -> Void
) {
showActionSheet(SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Confirm contact deletion?"),
buttons: [
.destructive(Text("Confirm")) {
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
},
.cancel()
]
),
id: "deleteNotReadyContact"
))
}
struct ChatInfoView_Previews: PreviewProvider { struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ChatInfoView( ChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData, contact: Contact.sampleData,
connectionStats: Binding.constant(nil),
customUserProfile: Binding.constant(nil),
localAlias: "", localAlias: "",
onSearch: {} connectionCode: Binding.constant(nil)
) )
} }
} }
@@ -9,7 +9,6 @@ import SwiftUI
class AnimatedImageView: UIView { class AnimatedImageView: UIView {
var image: UIImage? = nil var image: UIImage? = nil
var imageView: UIImageView? = nil var imageView: UIImageView? = nil
var cMode: UIView.ContentMode = .scaleAspectFit
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@@ -19,12 +18,11 @@ class AnimatedImageView: UIView {
fatalError("Not implemented") fatalError("Not implemented")
} }
convenience init(image: UIImage, contentMode: UIView.ContentMode) { convenience init(image: UIImage) {
self.init() self.init()
self.image = image self.image = image
self.cMode = contentMode
imageView = UIImageView(gifImage: image) imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!) self.addSubview(imageView!)
} }
@@ -37,7 +35,7 @@ class AnimatedImageView: UIView {
if let subview = self.subviews.first as? UIImageView { if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData { if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image) imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!) self.addSubview(imageView!)
subview.removeFromSuperview() subview.removeFromSuperview()
} }
@@ -49,15 +47,13 @@ class AnimatedImageView: UIView {
struct SwiftyGif: UIViewRepresentable { struct SwiftyGif: UIViewRepresentable {
private let image: UIImage private let image: UIImage
private let contentMode: UIView.ContentMode
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) { init(image: UIImage) {
self.image = image self.image = image
self.contentMode = contentMode
} }
func makeUIView(context: Context) -> AnimatedImageView { func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image, contentMode: contentMode) AnimatedImageView(image: image)
} }
func updateUIView(_ imageView: AnimatedImageView, context: Context) { func updateUIView(_ imageView: AnimatedImageView, context: Context) {
@@ -11,7 +11,6 @@ import SimpleXChat
struct CICallItemView: View { struct CICallItemView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var status: CICallStatus var status: CICallStatus
@@ -23,7 +22,7 @@ struct CICallItemView: View {
switch status { switch status {
case .pending: case .pending:
if sent { if sent {
Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary) Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
} else { } else {
acceptCallButton() acceptCallButton()
} }
@@ -36,7 +35,9 @@ struct CICallItemView: View {
case .error: missedCallIcon(sent).foregroundColor(.orange) case .error: missedCallIcon(sent).foregroundColor(.orange)
} }
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false) chatItem.timestampText
.font(.caption)
.foregroundColor(.secondary)
.padding(.bottom, 8) .padding(.bottom, 8)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
@@ -53,7 +54,7 @@ struct CICallItemView: View {
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View { @ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
HStack { HStack {
Image(systemName: "phone.down") Image(systemName: "phone.down")
Text(durationText(duration)).foregroundColor(theme.colors.secondary) Text(durationText(duration)).foregroundColor(.secondary)
} }
} }
@@ -71,7 +72,7 @@ struct CICallItemView: View {
Label("Answer call", systemImage: "phone.arrow.down.left") Label("Answer call", systemImage: "phone.arrow.down.left")
} }
} else { } else {
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary) Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
} }
} }
} }
@@ -11,9 +11,6 @@ import SimpleXChat
struct CIChatFeatureView: View { struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool @Binding var revealed: Bool
var feature: Feature var feature: Feature
@@ -21,7 +18,7 @@ struct CIChatFeatureView: View {
var iconColor: Color var iconColor: Color
var body: some View { var body: some View {
if !revealed, let fs = mergedFeatures() { if !revealed, let fs = mergedFeautures() {
HStack { HStack {
ForEach(fs, content: featureIconView) ForEach(fs, content: featureIconView)
} }
@@ -50,12 +47,12 @@ struct CIChatFeatureView: View {
} }
} }
private func mergedFeatures() -> [FeatureInfo]? { private func mergedFeautures() -> [FeatureInfo]? {
var fs: [FeatureInfo] = [] var fs: [FeatureInfo] = []
var icons: Set<String> = [] var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) { if var i = m.getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count, while i < m.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) { let f = featureInfo(m.reversedChatItems[i]) {
if !icons.contains(f.icon) { if !icons.contains(f.icon) {
fs.insert(f, at: 0) fs.insert(f, at: 0)
icons.insert(f.icon) icons.insert(f.icon)
@@ -68,10 +65,10 @@ struct CIChatFeatureView: View {
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? { private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
switch ci.content { switch ci.content {
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param) case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param) case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param) case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param) case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
default: nil default: nil
} }
} }
@@ -83,7 +80,7 @@ struct CIChatFeatureView: View {
if let param = f.param { if let param = f.param {
HStack { HStack {
i i
chatEventText(Text(param), theme.colors.secondary).lineLimit(1) chatEventText(Text(param)).lineLimit(1)
} }
} else { } else {
i i
@@ -95,7 +92,7 @@ struct CIChatFeatureView: View {
Image(systemName: icon ?? feature.iconFilled) Image(systemName: icon ?? feature.iconFilled)
.foregroundColor(iconColor) .foregroundColor(iconColor)
.scaleEffect(feature.iconScale) .scaleEffect(feature.iconScale)
chatEventText(chatItem, theme.colors.secondary) chatEventText(chatItem)
} }
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 4) .padding(.vertical, 4)
@@ -106,6 +103,6 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider { struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false) let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)) CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
} }
} }
@@ -17,7 +17,6 @@ struct CIEventView: View {
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 4) .padding(.vertical, 4)
.textSelection(.disabled) .textSelection(.disabled)
.lineLimit(4)
} }
} }
@@ -11,7 +11,6 @@ import SimpleXChat
struct CIFeaturePreferenceView: View { struct CIFeaturePreferenceView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
var feature: ChatFeature var feature: ChatFeature
var allowed: FeatureAllowed var allowed: FeatureAllowed
@@ -20,7 +19,7 @@ struct CIFeaturePreferenceView: View {
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
Image(systemName: feature.icon) Image(systemName: feature.icon)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.scaleEffect(feature.iconScale) .scaleEffect(feature.iconScale)
if let ct = chat.chatInfo.contact, if let ct = chat.chatInfo.contact,
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) { allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
@@ -41,17 +40,17 @@ struct CIFeaturePreferenceView: View {
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View { private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ") var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
if let acceptText { if let acceptText {
r = r r = r
+ Text(acceptText) + Text(acceptText)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
+ Text(" ") + Text(" ")
} }
r = r + chatItem.timestampText r = r + chatItem.timestampText
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
return r.font(.caption) return r.font(.caption)
} }
} }
@@ -11,48 +11,42 @@ import SimpleXChat
struct CIFileView: View { struct CIFileView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let file: CIFile? let file: CIFile?
let edited: Bool let edited: Bool
var smallViewSize: CGFloat?
var body: some View { var body: some View {
if smallViewSize != nil { let metaReserve = edited
fileIndicator() ? " "
.onTapGesture(perform: fileAction) : " "
} else { Button(action: fileAction) {
let metaReserve = edited HStack(alignment: .bottom, spacing: 6) {
? " " fileIndicator()
: " " .padding(.top, 5)
Button(action: fileAction) { .padding(.bottom, 3)
HStack(alignment: .bottom, spacing: 6) { if let file = file {
fileIndicator() let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
.padding(.top, 5) VStack(alignment: .leading, spacing: 2) {
.padding(.bottom, 3) Text(file.fileName)
if let file = file { .lineLimit(1)
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary) .multilineTextAlignment(.leading)
VStack(alignment: .leading, spacing: 2) { .foregroundColor(.primary)
Text(file.fileName) Text(prettyFileSize + metaReserve)
.lineLimit(1) .font(.caption)
.multilineTextAlignment(.leading) .lineLimit(1)
.foregroundColor(theme.colors.onBackground) .multilineTextAlignment(.leading)
Text(prettyFileSize + metaReserve) .foregroundColor(.secondary)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
} }
} else {
Text(metaReserve)
} }
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
} }
.disabled(!itemInteractive) .padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
} }
.disabled(!itemInteractive)
} }
private var itemInteractive: Bool { private var itemInteractive: Bool {
@@ -60,32 +54,36 @@ struct CIFileView: View {
switch (file.fileStatus) { switch (file.fileStatus) {
case .sndStored: return file.fileProtocol == .local case .sndStored: return file.fileProtocol == .local
case .sndTransfer: return false case .sndTransfer: return false
case .sndComplete: return true case .sndComplete: return false
case .sndCancelled: return false case .sndCancelled: return false
case .sndError: return true case .sndError: return false
case .sndWarning: return true
case .rcvInvitation: return true case .rcvInvitation: return true
case .rcvAccepted: return true case .rcvAccepted: return true
case .rcvTransfer: return false case .rcvTransfer: return false
case .rcvAborted: return true
case .rcvComplete: return true case .rcvComplete: return true
case .rcvCancelled: return false case .rcvCancelled: return false
case .rcvError: return true case .rcvError: return false
case .rcvWarning: return true
case .invalid: return false case .invalid: return false
} }
} }
return false return false
} }
private func fileSizeValid() -> Bool {
if let file = file {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
}
return false
}
private func fileAction() { private func fileAction() {
logger.debug("CIFileView fileAction") logger.debug("CIFileView fileAction")
if let file = file { if let file = file {
switch (file.fileStatus) { switch (file.fileStatus) {
case .rcvInvitation, .rcvAborted: case .rcvInvitation:
if fileSizeValid(file) { if fileSizeValid() {
Task { Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
if let user = m.currentUser { if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId) await receiveFile(user: user, fileId: file.fileId)
} }
@@ -116,40 +114,11 @@ struct CIFileView: View {
if let fileSource = getLoadedFileSource(file) { if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource) saveCryptoFile(fileSource)
} }
case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError")
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
case let .rcvWarning(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvWarning")
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
case .sndStored: case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored") logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource) saveCryptoFile(fileSource)
} }
case .sndComplete:
logger.debug("CIFileView fileAction - in .sndComplete")
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
}
case let .sndError(sndFileError):
logger.debug("CIFileView fileAction - in .sndError")
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
case let .sndWarning(sndFileError):
logger.debug("CIFileView fileAction - in .sndWarning")
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
default: break default: break
} }
} }
@@ -173,10 +142,9 @@ struct CIFileView: View {
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10) case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
case .rcvInvitation: case .rcvInvitation:
if fileSizeValid(file) { if fileSizeValid() {
fileIcon("arrow.down.doc.fill", color: theme.colors.primary) fileIcon("arrow.down.doc.fill", color: .accentColor)
} else { } else {
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
} }
@@ -187,12 +155,9 @@ struct CIFileView: View {
} else { } else {
progressView() progressView()
} }
case .rcvAborted:
fileIcon("doc.fill", color: theme.colors.primary, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
case .rcvComplete: fileIcon("doc.fill") case .rcvComplete: fileIcon("doc.fill")
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10) case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
} }
} else { } else {
@@ -201,22 +166,21 @@ struct CIFileView: View {
} }
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View { private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
let size = smallViewSize ?? 30 ZStack(alignment: .center) {
return ZStack(alignment: .center) {
Image(systemName: icon) Image(systemName: icon)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: 30, height: 30)
.foregroundColor(color) .foregroundColor(color)
if let innerIcon = innerIcon, if let innerIcon = innerIcon,
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) { let innerIconSize = innerIconSize {
Image(systemName: innerIcon) Image(systemName: innerIcon)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(maxHeight: 16) .frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize) .frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.top, size / 2.5) .padding(.top, 12)
} }
} }
} }
@@ -237,13 +201,6 @@ struct CIFileView: View {
} }
} }
func fileSizeValid(_ file: CIFile?) -> Bool {
if let file = file {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
}
return false
}
func saveCryptoFile(_ fileSource: CryptoFile) { func saveCryptoFile(_ fileSource: CryptoFile) {
if let cfArgs = fileSource.cryptoArgs { if let cfArgs = fileSource.cryptoArgs {
let url = getAppFilePath(fileSource.filePath) let url = getAppFilePath(fileSource.filePath)
@@ -11,8 +11,7 @@ import SimpleXChat
struct CIGroupInvitationView: View { struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var groupInvitation: CIGroupInvitation var groupInvitation: CIGroupInvitation
var memberRole: GroupMemberRole var memberRole: GroupMemberRole
@@ -21,8 +20,6 @@ struct CIGroupInvitationView: View {
@State private var inProgress = false @State private var inProgress = false
@State private var progressByTimeout = false @State private var progressByTimeout = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View { var body: some View {
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
let v = ZStack(alignment: .bottomTrailing) { let v = ZStack(alignment: .bottomTrailing) {
@@ -40,22 +37,16 @@ struct CIGroupInvitationView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
groupInvitationText() groupInvitationText()
.overlay(DetermineWidth()) .overlay(DetermineWidth())
( Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
Text(chatIncognito ? "Tap to join incognito" : "Tap to join") .foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary) .font(.callout)
.font(.callout) .padding(.trailing, 60)
+ Text(" ") .overlay(DetermineWidth())
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
)
.overlay(DetermineWidth())
} }
} else { } else {
( groupInvitationText()
groupInvitationText() .padding(.trailing, 60)
+ Text(" ") .overlay(DetermineWidth())
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
)
.overlay(DetermineWidth())
} }
} }
.padding(.bottom, 2) .padding(.bottom, 2)
@@ -65,11 +56,14 @@ struct CIGroupInvitationView: View {
} }
} }
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false) chatItem.timestampText
.font(.caption)
.foregroundColor(.secondary)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme)) .background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in .onChange(of: inProgress) { inProgress in
@@ -98,7 +92,7 @@ struct CIGroupInvitationView: View {
private func groupInfoView(_ action: Bool) -> some View { private func groupInfoView(_ action: Bool) -> some View {
var color: Color var color: Color
if action && !inProgress { if action && !inProgress {
color = chatIncognito ? .indigo : theme.colors.primary color = chatIncognito ? .indigo : .accentColor
} else { } else {
color = Color(uiColor: .tertiaryLabel) color = Color(uiColor: .tertiaryLabel)
} }
@@ -106,9 +100,9 @@ struct CIGroupInvitationView: View {
ProfileImage( ProfileImage(
imageStr: groupInvitation.groupProfile.image, imageStr: groupInvitation.groupProfile.image,
iconName: "person.2.circle.fill", iconName: "person.2.circle.fill",
size: 44,
color: color color: color
) )
.frame(width: 44, height: 44)
.padding(.trailing, 4) .padding(.trailing, 4)
VStack(alignment: .leading) { VStack(alignment: .leading) {
let p = groupInvitation.groupProfile let p = groupInvitation.groupProfile
@@ -121,7 +115,7 @@ struct CIGroupInvitationView: View {
} }
} }
private func groupInvitationText() -> Text { private func groupInvitationText() -> some View {
Text(groupInvitationStr()) Text(groupInvitationStr())
.font(.callout) .font(.callout)
} }
@@ -143,8 +137,8 @@ struct CIGroupInvitationView: View {
struct CIGroupInvitationView_Previews: PreviewProvider { struct CIGroupInvitationView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin) CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin)
CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin) CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin)
} }
} }
} }
@@ -11,41 +11,34 @@ import SimpleXChat
struct CIImageView: View { struct CIImageView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
let chatItem: ChatItem let chatItem: ChatItem
var preview: UIImage? let image: String
let maxWidth: CGFloat let maxWidth: CGFloat
var imgWidth: CGFloat? @Binding var imgWidth: CGFloat?
var smallView: Bool = false @State var scrollProxy: ScrollViewProxy?
@Binding var showFullScreenImage: Bool @State var metaColor: Color
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0 @State private var showFullScreenImage = false
var body: some View { var body: some View {
let file = chatItem.file let file = chatItem.file
VStack(alignment: .center, spacing: 6) { VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) { if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } } imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) { .fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage) FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
} }
.onTapGesture { showFullScreenImage = true } .onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false showFullScreenImage = false
} }
} else if let preview { } else if let data = Data(base64Encoded: dropImagePrefix(image)),
Group { let uiImage = UIImage(data: data) {
if smallView { imageView(uiImage)
smallViewImageView(preview)
} else {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
}
}
.onTapGesture { .onTapGesture {
if let file = file { if let file = file {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation, .rcvAborted: case .rcvInvitation:
Task { Task {
if let user = m.currentUser { if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId) await receiveFile(user: user, fileId: file.fileId)
@@ -68,70 +61,29 @@ struct CIImageView: View {
case .rcvTransfer: () // ? case .rcvTransfer: () // ?
case .rcvComplete: () // ? case .rcvComplete: () // ?
case .rcvCancelled: () // TODO case .rcvCancelled: () // TODO
case let .rcvError(rcvFileError):
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
case let .rcvWarning(rcvFileError):
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
case let .sndError(sndFileError):
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
case let .sndWarning(sndFileError):
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
default: () default: ()
} }
} }
} }
} }
} }
.onDisappear {
showFullScreenImage = false
}
} }
private func imageView(_ img: UIImage) -> some View { private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth let w = img.size.width <= img.size.height ? maxWidth * 0.75 : img.imageData == nil ? .infinity : maxWidth
DispatchQueue.main.async { imgWidth = w }
return ZStack(alignment: .topTrailing) { return ZStack(alignment: .topTrailing) {
if img.imageData == nil { if img.imageData == nil {
Image(uiImage: img) Image(uiImage: img)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: w) .frame(maxWidth: w)
} else { } else {
SwiftyGif(image: img) SwiftyGif(image: img)
.frame(width: w, height: w * img.size.height / img.size.width) .frame(width: w, height: w * img.size.height / img.size.width)
.scaledToFit() .scaledToFit()
} }
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) { loadingIndicator()
loadingIndicator()
}
}
}
private func smallViewImageView(_ img: UIImage) -> some View {
ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
} else {
SwiftyGif(image: img, contentMode: .scaleAspectFill)
.frame(width: maxWidth, height: maxWidth)
}
if chatItem.file?.showStatusIconInSmallView == true {
loadingIndicator()
}
} }
} }
@@ -148,16 +100,13 @@ struct CIImageView: View {
case .sndComplete: fileIcon("checkmark", 10, 13) case .sndComplete: fileIcon("checkmark", 10, 13)
case .sndCancelled: fileIcon("xmark", 10, 13) case .sndCancelled: fileIcon("xmark", 10, 13)
case .sndError: fileIcon("xmark", 10, 13) case .sndError: fileIcon("xmark", 10, 13)
case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11) case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case .rcvTransfer: progressView() case .rcvTransfer: progressView()
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvComplete: EmptyView()
case .rcvCancelled: fileIcon("xmark", 10, 13) case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13) case .rcvError: fileIcon("xmark", 10, 13)
case .rcvWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13) case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView()
} }
} }
} }
@@ -167,7 +116,7 @@ struct CIImageView: View {
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(.white) .foregroundColor(metaColor)
.padding(padding) .padding(padding)
} }
@@ -178,12 +127,4 @@ struct CIImageView: View {
.tint(.white) .tint(.white)
.padding(8) .padding(8)
} }
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
} }
@@ -9,7 +9,6 @@
import SwiftUI import SwiftUI
struct CIInvalidJSONView: View { struct CIInvalidJSONView: View {
@EnvironmentObject var theme: AppTheme
var json: String var json: String
@State private var showJSON = false @State private var showJSON = false
@@ -22,9 +21,10 @@ struct CIInvalidJSONView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground)) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
.onTapGesture { showJSON = true } .onTapGesture { showJSON = true }
.appSheet(isPresented: $showJSON) { .sheet(isPresented: $showJSON) {
invalidJSONView(json) invalidJSONView(json)
} }
} }
@@ -44,7 +44,6 @@ func invalidJSONView(_ json: String) -> some View {
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.padding() .padding()
.modifier(ThemedBackground())
} }
struct CIInvalidJSONView_Previews: PreviewProvider { struct CIInvalidJSONView_Previews: PreviewProvider {
@@ -10,17 +10,16 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CILinkView: View { struct CILinkView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let linkPreview: LinkPreview let linkPreview: LinkPreview
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View { var body: some View {
VStack(alignment: .center, spacing: 6) { VStack(alignment: .center, spacing: 6) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) { if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred))
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title) Text(linkPreview.title)
@@ -33,7 +32,7 @@ struct CILinkView: View {
Text(linkPreview.uri.absoluteString) Text(linkPreview.uri.absoluteString)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -11,7 +11,6 @@ import SimpleXChat
struct CIMemberCreatedContactView: View { struct CIMemberCreatedContactView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
@@ -44,12 +43,12 @@ struct CIMemberCreatedContactView: View {
r = r r = r
+ Text(openText) + Text(openText)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
+ Text(" ") + Text(" ")
} }
r = r + chatItem.timestampText r = r + chatItem.timestampText
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
return r.font(.caption) return r.font(.caption)
} }
@@ -57,11 +56,11 @@ struct CIMemberCreatedContactView: View {
if let member = chatItem.memberDisplayName { if let member = chatItem.memberDisplayName {
return Text(member + " " + chatItem.content.text + " ") return Text(member + " " + chatItem.content.text + " ")
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} else { } else {
return Text(chatItem.content.text + " ") return Text(chatItem.content.text + " ")
.fontWeight(.light) .fontWeight(.light)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
} }
@@ -11,14 +11,9 @@ import SimpleXChat
struct CIMetaView: View { struct CIMetaView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
var metaColor: Color var metaColor = Color.secondary
var paleMetaColor = Color(UIColor.tertiaryLabel) var paleMetaColor = Color(UIColor.tertiaryLabel)
var showStatus = true
var showEdited = true
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View { var body: some View {
if chatItem.isDeletedContent { if chatItem.isDeletedContent {
@@ -30,24 +25,24 @@ struct CIMetaView: View {
switch meta.itemStatus { switch meta.itemStatus {
case let .sndSent(sndProgress): case let .sndSent(sndProgress):
switch sndProgress { switch sndProgress {
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent)
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent)
} }
case let .sndRcvd(_, sndProgress): case let .sndRcvd(_, sndProgress):
switch sndProgress { switch sndProgress {
case .complete: case .complete:
ZStack { ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2)
} }
case .partial: case .partial:
ZStack { ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2)
} }
} }
default: default:
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor)
} }
} }
} }
@@ -59,20 +54,9 @@ enum SentCheckmark {
case rcvd2 case rcvd2
} }
func ciMetaText( func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text {
_ meta: CIMeta,
chatTTL: Int?,
encrypted: Bool?,
color: Color = .clear,
primaryColor: Color = .accentColor,
transparent: Bool = false,
sent: SentCheckmark? = nil,
showStatus: Bool = true,
showEdited: Bool = true,
showViaProxy: Bool
) -> Text {
var r = Text("") var r = Text("")
if showEdited, meta.itemEdited { if meta.itemEdited {
r = r + statusIconText("pencil", color) r = r + statusIconText("pencil", color)
} }
if meta.disappearing { if meta.disappearing {
@@ -83,24 +67,19 @@ func ciMetaText(
} }
r = r + Text(" ") r = r + Text(" ")
} }
if showViaProxy, meta.sentViaProxy == true { if let (icon, statusColor) = meta.statusIcon(color) {
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2) let t = Text(Image(systemName: icon)).font(.caption2)
} let gap = Text(" ").kerning(-1.25)
if showStatus { let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
if let (icon, statusColor) = meta.statusIcon(color, primaryColor) { switch sent {
let t = Text(Image(systemName: icon)).font(.caption2) case nil: r = r + t1
let gap = Text(" ").kerning(-1.25) case .sent: r = r + t1 + gap
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
switch sent { case .rcvd2: r = r + gap + t1
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(" ")
} else if !meta.disappearing {
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
} }
r = r + Text(" ")
} else if !meta.disappearing {
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
} }
if let enc = encrypted { if let enc = encrypted {
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ") r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
@@ -114,16 +93,15 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
} }
struct CIMetaView_Previews: PreviewProvider { struct CIMetaView_Previews: PreviewProvider {
static let metaColor = Color.secondary
static var previews: some View { static var previews: some View {
Group { Group {
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)), metaColor: metaColor) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)), metaColor: metaColor) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), metaColor: metaColor) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)), metaColor: metaColor) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)), metaColor: metaColor) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true), metaColor: metaColor) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
} }
.previewLayout(.fixed(width: 360, height: 100)) .previewLayout(.fixed(width: 360, height: 100))
} }
@@ -13,21 +13,18 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
struct CIRcvDecryptionError: View { struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var msgDecryptError: MsgDecryptError var msgDecryptError: MsgDecryptError
var msgCount: UInt32 var msgCount: UInt32
var chatItem: ChatItem var chatItem: ChatItem
@State private var alert: CIRcvDecryptionErrorAlert? @State private var alert: CIRcvDecryptionErrorAlert?
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
enum CIRcvDecryptionErrorAlert: Identifiable { enum CIRcvDecryptionErrorAlert: Identifiable {
case syncAllowedAlert(_ syncConnection: () -> Void) case syncAllowedAlert(_ syncConnection: () -> Void)
case syncNotSupportedContactAlert case syncNotSupportedContactAlert
case syncNotSupportedMemberAlert case syncNotSupportedMemberAlert
case decryptionErrorAlert case decryptionErrorAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String { var id: String {
switch self { switch self {
@@ -62,7 +59,7 @@ struct CIRcvDecryptionError: View {
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message()) 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 .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message()) case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
} }
} }
@@ -115,23 +112,24 @@ struct CIRcvDecryptionError: View {
} }
( (
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath")) Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? .accentColor : .secondary)
.font(.callout) .font(.callout)
+ Text(" ") + Text(" ")
+ Text("Fix connection") + Text("Fix connection")
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? .accentColor : .secondary)
.font(.callout) .font(.callout)
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
) )
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground)) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -142,15 +140,16 @@ struct CIRcvDecryptionError: View {
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground)) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -13,53 +13,63 @@ import Combine
struct CIVideoView: View { struct CIVideoView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
private let chatItem: ChatItem private let chatItem: ChatItem
private let preview: UIImage? private let image: String
@State private var duration: Int @State private var duration: Int
@State private var progress: Int = 0 @State private var progress: Int = 0
@State private var videoPlaying: Bool = false @State private var videoPlaying: Bool = false
private let maxWidth: CGFloat private let maxWidth: CGFloat
private var videoWidth: CGFloat? @Binding private var videoWidth: CGFloat?
private let smallView: Bool @State private var scrollProxy: ScrollViewProxy?
@State private var preview: UIImage? = nil
@State private var player: AVPlayer? @State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer? @State private var fullPlayer: AVPlayer?
@State private var url: URL? @State private var url: URL?
@State private var urlDecrypted: URL? @State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false @State private var decryptionInProgress: Bool = false
@Binding private var showFullScreenPlayer: Bool @State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil @State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil @State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil @State private var publisher: AnyCancellable? = nil
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) { init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
self.chatItem = chatItem self.chatItem = chatItem
self.preview = preview self.image = image
self._duration = State(initialValue: duration) self._duration = State(initialValue: duration)
self.maxWidth = maxWidth self.maxWidth = maxWidth
self.videoWidth = videoWidth self._videoWidth = videoWidth
self.smallView = smallView self.scrollProxy = scrollProxy
self._showFullScreenPlayer = showFullscreenPlayer if let url = getLoadedVideo(chatItem.file) {
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
self._urlDecrypted = State(initialValue: decrypted)
if let decrypted = decrypted {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
}
self._url = State(initialValue: url)
}
if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
self._preview = State(initialValue: uiImage)
}
} }
var body: some View { var body: some View {
let file = chatItem.file let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) { ZStack {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView { if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
smallVideoView(decrypted, file, preview)
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
videoView(player, decrypted, file, preview, duration) videoView(player, decrypted, file, preview, duration)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
smallVideoViewEncrypted(file, defaultPreview)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil { } else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
videoViewEncrypted(file, defaultPreview, duration) videoViewEncrypted(file, defaultPreview, duration)
} else if let preview, let file { } else if let data = Data(base64Encoded: dropImagePrefix(image)),
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } } let uiImage = UIImage(data: data) {
.onTapGesture { imageView(uiImage)
.onTapGesture {
if let file = file {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation, .rcvAborted: case .rcvInvitation:
receiveFileIfValidSize(file: file, receiveFile: receiveFile) receiveFileIfValidSize(file: file, receiveFile: receiveFile)
case .rcvAccepted: case .rcvAccepted:
switch file.fileProtocol { switch file.fileProtocol {
@@ -81,69 +91,18 @@ struct CIVideoView: View {
default: () default: ()
} }
} }
}
if !smallView {
durationProgress()
}
}
if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
} }
} else if !file.showStatusIconInSmallView { }
durationProgress()
}
if let file = file, case .rcvInvitation = file.fileStatus {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill") playPauseIcon("play.fill")
.onTapGesture {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
}
} }
} }
} }
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onAppear {
setupPlayer(chatItem.file)
}
.onChange(of: chatItem.file) { file in
// ChatItem can be changed in small view on chat list screen
setupPlayer(file)
}
.onDisappear {
showFullScreenPlayer = false
}
}
private func setupPlayer(_ file: CIFile?) {
let newUrl = getLoadedVideo(file)
if newUrl == url {
return
}
url = nil
urlDecrypted = nil
player = nil
fullPlayer = nil
if let newUrl {
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
urlDecrypted = decrypted
if let decrypted = decrypted {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
url = newUrl
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
} }
private func videoViewEncrypted(_ file: CIFile, _ defaultPreview: UIImage, _ duration: Int) -> some View { private func videoViewEncrypted(_ file: CIFile, _ defaultPreview: UIImage, _ duration: Int) -> some View {
@@ -151,6 +110,11 @@ struct CIVideoView: View {
ZStack(alignment: .center) { ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview) imageView(defaultPreview)
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onTapGesture { .onTapGesture {
decrypt(file: file) { decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil showFullScreenPlayer = urlDecrypted != nil
@@ -159,22 +123,20 @@ struct CIVideoView: View {
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false showFullScreenPlayer = false
} }
if !blurred { if !decryptionInProgress {
if !decryptionInProgress { Button {
Button { decrypt(file: file) {
decrypt(file: file) { if let decrypted = urlDecrypted {
if urlDecrypted != nil { videoPlaying = true
videoPlaying = true player?.play()
player?.play()
}
} }
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} }
.disabled(!canBePlayed) } label: {
} else { playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
videoDecryptionProgress()
} }
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
} }
} }
} }
@@ -182,6 +144,7 @@ struct CIVideoView: View {
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View { private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) { return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) { ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
@@ -193,7 +156,9 @@ struct CIVideoView: View {
videoPlaying = false videoPlaying = false
} }
} }
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred)) .fullScreenCover(isPresented: $showFullScreenPlayer) {
fullScreenPlayer(url)
}
.onTapGesture { .onTapGesture {
switch player.timeControlStatus { switch player.timeControlStatus {
case .playing: case .playing:
@@ -209,7 +174,7 @@ struct CIVideoView: View {
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false showFullScreenPlayer = false
} }
if !videoPlaying && !blurred { if !videoPlaying {
Button { Button {
m.stopPreviousRecPlay = url m.stopPreviousRecPlay = url
player.play() player.play()
@@ -219,7 +184,7 @@ struct CIVideoView: View {
.disabled(!canBePlayed) .disabled(!canBePlayed)
} }
} }
fileStatusIcon() loadingIndicator()
} }
.onAppear { .onAppear {
addObserver(player, url) addObserver(player, url)
@@ -231,53 +196,14 @@ struct CIVideoView: View {
} }
} }
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if file.showStatusIconInSmallView {
// Show nothing
} else if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} else {
videoDecryptionProgress()
}
}
}
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
.onTapGesture {
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View { private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12) .frame(width: 12, height: 12)
.foregroundColor(color) .foregroundColor(color)
.padding(.leading, smallView ? 0 : 4) .padding(.leading, 4)
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier) .frame(width: 40, height: 40)
.background(Color.black.opacity(0.35)) .background(Color.black.opacity(0.35))
.clipShape(Circle()) .clipShape(Circle())
} }
@@ -285,9 +211,9 @@ struct CIVideoView: View {
private func videoDecryptionProgress(_ color: Color = .white) -> some View { private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12) .frame(width: 12, height: 12)
.tint(color) .tint(color)
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40) .frame(width: 40, height: 40)
.background(Color.black.opacity(0.35)) .background(Color.black.opacity(0.35))
.clipShape(Circle()) .clipShape(Circle())
} }
@@ -317,33 +243,18 @@ struct CIVideoView: View {
} }
private func imageView(_ img: UIImage) -> some View { private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth let w = img.size.width <= img.size.height ? maxWidth * 0.75 : .infinity
DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) { return ZStack(alignment: .topTrailing) {
Image(uiImage: img) Image(uiImage: img)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: w) .frame(maxWidth: w)
.modifier(PrivacyBlur(blurred: $blurred)) loadingIndicator()
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
fileStatusIcon()
}
} }
} }
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View { @ViewBuilder private func loadingIndicator() -> some View {
ZStack(alignment: .center) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
if file.showStatusIconInSmallView {
fileStatusIcon()
.allowsHitTesting(false)
}
}
}
@ViewBuilder private func fileStatusIcon() -> some View {
if let file = chatItem.file { if let file = chatItem.file {
switch file.fileStatus { switch file.fileStatus {
case .sndStored: case .sndStored:
@@ -360,22 +271,7 @@ struct CIVideoView: View {
} }
case .sndComplete: fileIcon("checkmark", 10, 13) case .sndComplete: fileIcon("checkmark", 10, 13)
case .sndCancelled: fileIcon("xmark", 10, 13) case .sndCancelled: fileIcon("xmark", 10, 13)
case let .sndError(sndFileError): case .sndError: fileIcon("xmark", 10, 13)
fileIcon("xmark", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
}
case let .sndWarning(sndFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
}
case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11) case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case let .rcvTransfer(rcvProgress, rcvTotal): case let .rcvTransfer(rcvProgress, rcvTotal):
@@ -384,26 +280,10 @@ struct CIVideoView: View {
} else { } else {
progressView() progressView()
} }
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvComplete: EmptyView()
case .rcvCancelled: fileIcon("xmark", 10, 13) case .rcvCancelled: fileIcon("xmark", 10, 13)
case let .rcvError(rcvFileError): case .rcvError: fileIcon("xmark", 10, 13)
fileIcon("xmark", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
}
case let .rcvWarning(rcvFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
}
case .invalid: fileIcon("questionmark", 10, 13) case .invalid: fileIcon("questionmark", 10, 13)
default: EmptyView()
} }
} }
} }
@@ -414,7 +294,7 @@ struct CIVideoView: View {
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(.white) .foregroundColor(.white)
.padding(smallView ? 0 : padding) .padding(padding)
} }
private func progressView() -> some View { private func progressView() -> some View {
@@ -422,7 +302,7 @@ struct CIVideoView: View {
.progressViewStyle(.circular) .progressViewStyle(.circular)
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.tint(.white) .tint(.white)
.padding(smallView ? 0 : 11) .padding(11)
} }
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View { private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
@@ -434,14 +314,14 @@ struct CIVideoView: View {
) )
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11) .padding([.trailing, .top], 11)
} }
// TODO encrypt: where file size is checked? // TODO encrypt: where file size is checked?
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool) async -> Void) {
Task { Task {
if let user = m.currentUser { if let user = m.currentUser {
await receiveFile(user, file.fileId, false, false) await receiveFile(user, file.fileId, false)
} }
} }
} }
@@ -474,8 +354,7 @@ struct CIVideoView: View {
) )
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) { DispatchQueue.main.asyncAfter(deadline: .now()) {
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+ m.stopPreviousRecPlay = url
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
if let player = fullPlayer { if let player = fullPlayer {
player.play() player.play()
var played = false var played = false
@@ -512,12 +391,10 @@ struct CIVideoView: View {
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete) urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
await MainActor.run { await MainActor.run {
if let decrypted = urlDecrypted { if let decrypted = urlDecrypted {
if !smallView { player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
}
fullPlayer = AVPlayer(url: decrypted) fullPlayer = AVPlayer(url: decrypted)
} }
decryptionInProgress = false decryptionInProgress = true
completed?() completed?()
} }
} }
@@ -11,30 +11,18 @@ import SimpleXChat
struct CIVoiceView: View { struct CIVoiceView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
let recordingFile: CIFile? let recordingFile: CIFile?
let duration: Int let duration: Int
@State var audioPlayer: AudioPlayer? = nil @Binding var audioPlayer: AudioPlayer?
@State var playbackState: VoiceMessagePlaybackState = .noPlayback @Binding var playbackState: VoiceMessagePlaybackState
@State var playbackTime: TimeInterval? = nil @Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
var smallViewSize: CGFloat?
@State private var seek: (TimeInterval) -> Void = { _ in } @State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View { var body: some View {
Group { Group {
if smallViewSize != nil { if chatItem.chatDir.sent {
HStack(spacing: 10) {
player()
playerTime()
.allowsHitTesting(false)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
} else if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) { VStack (alignment: .trailing, spacing: 6) {
HStack { HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu { if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
@@ -65,13 +53,7 @@ struct CIVoiceView: View {
} }
private func player() -> some View { private func player() -> some View {
let sizeMultiplier: CGFloat = if let sz = smallViewSize { VoiceMessagePlayer(
voiceMessageSizeBasedOnSquareSize(sz) / 56
} else {
1
}
return VoiceMessagePlayer(
chat: chat,
chatItem: chatItem, chatItem: chatItem,
recordingFile: recordingFile, recordingFile: recordingFile,
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
@@ -80,8 +62,7 @@ struct CIVoiceView: View {
audioPlayer: $audioPlayer, audioPlayer: $audioPlayer,
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime, playbackTime: $playbackTime,
allowMenu: $allowMenu, allowMenu: $allowMenu
sizeMultiplier: sizeMultiplier
) )
} }
@@ -91,7 +72,7 @@ struct CIVoiceView: View {
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime playbackTime: $playbackTime
) )
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
private func playbackSlider() -> some View { private func playbackSlider() -> some View {
@@ -108,11 +89,10 @@ struct CIVoiceView: View {
allowMenu = true allowMenu = true
} }
} }
.tint(theme.colors.primary)
} }
private func metaView() -> some View { private func metaView() -> some View {
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
} }
} }
@@ -137,9 +117,8 @@ struct VoiceMessagePlayerTime: View {
} }
struct VoiceMessagePlayer: View { struct VoiceMessagePlayer: View {
@ObservedObject var chat: Chat
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem var chatItem: ChatItem
var recordingFile: CIFile? var recordingFile: CIFile?
var recordingTime: TimeInterval var recordingTime: TimeInterval
@@ -149,61 +128,23 @@ struct VoiceMessagePlayer: View {
@Binding var audioPlayer: AudioPlayer? @Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval? @Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
var sizeMultiplier: CGFloat
var body: some View { var body: some View {
ZStack { ZStack {
if let recordingFile = recordingFile { if let recordingFile = recordingFile {
switch recordingFile.fileStatus { switch recordingFile.fileStatus {
case .sndStored: case .sndStored: playbackButton()
if recordingFile.fileProtocol == .local { case .sndTransfer: playbackButton()
playbackButton()
} else {
loadingIcon()
}
case .sndTransfer: loadingIcon()
case .sndComplete: playbackButton() case .sndComplete: playbackButton()
case .sndCancelled: playbackButton() case .sndCancelled: playbackButton()
case let .sndError(sndFileError): case .sndError: playbackButton()
fileStatusIcon("multiply", 14) case .rcvInvitation: downloadButton(recordingFile)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
}
case let .sndWarning(sndFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
}
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .rcvAccepted: loadingIcon() case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon() case .rcvTransfer: loadingIcon()
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
case .rcvComplete: playbackButton() case .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case let .rcvError(rcvFileError): case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
fileStatusIcon("multiply", 14)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
}
case let .rcvWarning(rcvFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
}
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
} }
} else { } else {
@@ -211,170 +152,84 @@ struct VoiceMessagePlayer: View {
} }
} }
.onAppear { .onAppear {
if audioPlayer == nil {
let small = sizeMultiplier != 1
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
}
seek = { to in audioPlayer?.seek(to) } seek = { to in audioPlayer?.seek(to) }
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) { audioPlayer?.onTimer = { playbackTime = $0 }
getAppFilePath(recordingSource.filePath)
} else {
nil
}
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
audioPlayer?.onTimer = {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
}
audioPlayer?.onFinishPlayback = { audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback playbackState = .noPlayback
playbackTime = TimeInterval(0) playbackTime = TimeInterval(0)
notifyStateChange()
}
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
stopPlayback()
} }
} }
.onChange(of: chatModel.stopPreviousRecPlay) { it in .onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath, if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) { chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
stopPlayback() audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
} }
} }
.onChange(of: playbackState) { state in .onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback allowMenu = state == .paused || state == .noPlayback
// Notify activeContentPreview in ChatPreviewView that playback is finished
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
chatModel.stopPreviousRecPlay = nil
}
}
.onChange(of: chatModel.chatId) { _ in
stopPlayback()
}
.onDisappear {
if sizeMultiplier == 1 && chatModel.chatId == nil {
stopPlayback()
}
} }
} }
@ViewBuilder private func playbackButton() -> some View { @ViewBuilder private func playbackButton() -> some View {
if sizeMultiplier != 1 { switch playbackState {
switch playbackState { case .noPlayback:
case .noPlayback: Button {
playPauseIcon("play.fill", theme.colors.primary) if let recordingSource = getLoadedFileSource(recordingFile) {
.onTapGesture { startPlayback(recordingSource)
if let recordingSource = getLoadedFileSource(recordingFile) { }
startPlayback(recordingSource) } label: {
} playPauseIcon("play.fill")
}
case .playing:
playPauseIcon("pause.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
}
case .paused:
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
}
} }
} else { case .playing:
switch playbackState { Button {
case .noPlayback: audioPlayer?.pause()
Button { playbackState = .paused
if let recordingSource = getLoadedFileSource(recordingFile) { } label: {
startPlayback(recordingSource) playPauseIcon("pause.fill")
} }
} label: { case .paused:
playPauseIcon("play.fill", theme.colors.primary) Button {
} audioPlayer?.play()
case .playing: playbackState = .playing
Button { } label: {
audioPlayer?.pause() playPauseIcon("play.fill")
playbackState = .paused
notifyStateChange()
} label: {
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
} }
} }
} }
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View { private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
ZStack { ZStack {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier) .frame(width: 20, height: 20)
.foregroundColor(color) .foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0) .padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier) .frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear) .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle()) .clipShape(Circle())
if recordingTime > 0 { if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime) ProgressCircle(length: recordingTime, progress: $playbackTime)
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter .frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
} }
} }
} }
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { private func downloadButton(_ recordingFile: CIFile) -> some View {
Group { Button {
if sizeMultiplier != 1 { Task {
playPauseIcon(icon, theme.colors.primary) if let user = chatModel.currentUser {
.onTapGesture { await receiveFile(user: user, fileId: recordingFile.fileId)
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
}
} else {
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
} label: {
playPauseIcon(icon, theme.colors.primary)
} }
} }
} } label: {
} playPauseIcon("play.fill")
func notifyStateChange() {
if sizeMultiplier != 1 {
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} else {
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} }
} }
private struct ProgressCircle: View { private struct ProgressCircle: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval var length: TimeInterval
@Binding var progress: TimeInterval? @Binding var progress: TimeInterval?
@@ -382,7 +237,7 @@ struct VoiceMessagePlayer: View {
Circle() Circle()
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length)) .trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
.stroke( .stroke(
theme.colors.primary, Color.accentColor,
style: StrokeStyle(lineWidth: 3) style: StrokeStyle(lineWidth: 3)
) )
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
@@ -390,100 +245,26 @@ struct VoiceMessagePlayer: View {
} }
} }
private func fileStatusIcon(_ image: String, _ size: CGFloat) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
private func loadingIcon() -> some View { private func loadingIcon() -> some View {
ProgressView() ProgressView()
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier) .frame(width: 30, height: 30)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier) .frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear) .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
.clipShape(Circle()) .clipShape(Circle())
} }
private func startPlayback(_ recordingSource: CryptoFile) { private func startPlayback(_ recordingSource: CryptoFile) {
let audioPath = getAppFilePath(recordingSource.filePath) chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
chatModel.stopPreviousRecPlay = audioPath
audioPlayer = AudioPlayer( audioPlayer = AudioPlayer(
onTimer: { onTimer: { playbackTime = $0 },
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
},
onFinishPlayback: { onFinishPlayback: {
playbackState = .noPlayback playbackState = .noPlayback
playbackTime = TimeInterval(0) playbackTime = TimeInterval(0)
notifyStateChange()
} }
) )
audioPlayer?.start(fileSource: recordingSource, at: playbackTime) audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
playbackState = .playing playbackState = .playing
notifyStateChange()
} }
private func stopPlayback() {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
}
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio)
}
class VoiceItemState {
var audioPlayer: AudioPlayer?
var playbackState: VoiceMessagePlaybackState
var playbackTime: TimeInterval?
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
self.audioPlayer = audioPlayer
self.playbackState = playbackState
self.playbackTime = playbackTime
}
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)"
}
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)"
}
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = smallView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = chatView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static var smallView: [String: VoiceItemState] = [:]
static var chatView: [String: VoiceItemState] = [:]
} }
struct CIVoiceView_Previews: PreviewProvider { struct CIVoiceView_Previews: PreviewProvider {
@@ -508,12 +289,15 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(), chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30, duration: 30,
audioPlayer: .constant(nil),
playbackState: .constant(.playing),
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true) allowMenu: Binding.constant(true)
) )
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
} }
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }
@@ -10,21 +10,22 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct DeletedItemView: View { struct DeletedItemView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
HStack(alignment: .bottom, spacing: 0) { HStack(alignment: .bottom, spacing: 0) {
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.italic() .italic()
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.padding(.leading, 12) .padding(.leading, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme)) .background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
} }
@@ -11,7 +11,6 @@ import SimpleXChat
struct EmojiItemView: View { struct EmojiItemView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
@@ -19,7 +18,7 @@ struct EmojiItemView: View {
emojiText(chatItem.content.text) emojiText(chatItem.content.text)
.padding(.top, 8) .padding(.top, 8)
.padding(.horizontal, 6) .padding(.horizontal, 6)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.bottom, 8) .padding(.bottom, 8)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
@@ -12,24 +12,21 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct FramedCIVoiceView: View { struct FramedCIVoiceView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
let recordingFile: CIFile? let recordingFile: CIFile?
let duration: Int let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var seek: (TimeInterval) -> Void = { _ in } @State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View { var body: some View {
HStack { HStack {
VoiceMessagePlayer( VoiceMessagePlayer(
chat: chat,
chatItem: chatItem, chatItem: chatItem,
recordingFile: recordingFile, recordingFile: recordingFile,
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
@@ -38,15 +35,14 @@ struct FramedCIVoiceView: View {
audioPlayer: $audioPlayer, audioPlayer: $audioPlayer,
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime, playbackTime: $playbackTime,
allowMenu: $allowMenu, allowMenu: $allowMenu
sizeMultiplier: 1
) )
VoiceMessagePlayerTime( VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration), recordingTime: TimeInterval(duration),
playbackState: $playbackState, playbackState: $playbackState,
playbackTime: $playbackTime playbackTime: $playbackTime
) )
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.frame(width: 50, alignment: .leading) .frame(width: 50, alignment: .leading)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu { if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider() playbackSlider()
File diff suppressed because one or more lines are too long
@@ -13,12 +13,12 @@ import AVKit
struct FullScreenMediaView: View { struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
@State var chatItem: ChatItem @State var chatItem: ChatItem
@State var image: UIImage? @State var image: UIImage?
@State var player: AVPlayer? = nil @State var player: AVPlayer? = nil
@State var url: URL? = nil @State var url: URL? = nil
@Binding var showView: Bool @Binding var showView: Bool
@State var scrollProxy: ScrollViewProxy?
@State private var showNext = false @State private var showNext = false
@State private var nextImage: UIImage? @State private var nextImage: UIImage?
@State private var nextPlayer: AVPlayer? @State private var nextPlayer: AVPlayer?
@@ -71,7 +71,9 @@ struct FullScreenMediaView: View {
let w = abs(t.width) let w = abs(t.width)
if t.height > 60 && t.height > w * 2 { if t.height > 60 && t.height > w * 2 {
showView = false showView = false
scrollModel.scrollToItem(id: chatItem.id) if let proxy = scrollProxy {
proxy.scrollTo(chatItem.viewId)
}
} else if w > 60 && w > abs(t.height) * 2 && !scrolling { } else if w > 60 && w > abs(t.height) * 2 && !scrolling {
let previous = t.width > 0 let previous = t.width > 0
scrolling = true scrolling = true
@@ -11,7 +11,6 @@ import SimpleXChat
struct IntegrityErrorItemView: View { struct IntegrityErrorItemView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var msgError: MsgErrorType var msgError: MsgErrorType
var chatItem: ChatItem var chatItem: ChatItem
@@ -55,7 +54,6 @@ struct IntegrityErrorItemView: View {
struct CIMsgError: View { struct CIMsgError: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
var onTap: () -> Void var onTap: () -> Void
@@ -64,12 +62,13 @@ struct CIMsgError: View {
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.padding(.leading, 12) .padding(.leading, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground)) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
.onTapGesture(perform: onTap) .onTapGesture(perform: onTap)
} }
@@ -11,7 +11,7 @@ import SimpleXChat
struct MarkedDeletedItemView: View { struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool @Binding var revealed: Bool
@@ -19,10 +19,11 @@ struct MarkedDeletedItemView: View {
var body: some View { var body: some View {
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText) (Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme)) .background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -35,8 +36,8 @@ struct MarkedDeletedItemView: View {
var blockedByAdmin = 0 var blockedByAdmin = 0
var deleted = 0 var deleted = 0
var moderatedBy: Set<String> = [] var moderatedBy: Set<String> = []
while i < ItemsModel.shared.reversedChatItems.count, while i < m.reversedChatItems.count,
let ci = .some(ItemsModel.shared.reversedChatItems[i]), let ci = .some(m.reversedChatItems[i]),
ci.mergeCategory == ciCategory, ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted { let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted { switch itemDeleted {
@@ -26,7 +26,6 @@ private func typing(_ w: Font.Weight = .light) -> Text {
struct MsgContentView: View { struct MsgContentView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var text: String var text: String
var formattedText: [FormattedText]? = nil var formattedText: [FormattedText]? = nil
var sender: String? = nil var sender: String? = nil
@@ -36,8 +35,6 @@ struct MsgContentView: View {
@State private var typingIdx = 0 @State private var typingIdx = 0
@State private var timer: Timer? @State private var timer: Timer?
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View { var body: some View {
if meta?.isLive == true { if meta?.isLive == true {
msgContentView() msgContentView()
@@ -66,7 +63,7 @@ struct MsgContentView: View {
} }
private func msgContentView() -> Text { private func msgContentView() -> Text {
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary) var v = messageText(text, formattedText, sender, showSecrets: showSecrets)
if let mt = meta { if let mt = meta {
if mt.isLive { if mt.isLive {
v = v + typingIndicator(mt.recent) v = v + typingIndicator(mt.recent)
@@ -80,15 +77,15 @@ struct MsgContentView: View {
return (recent ? typingIndicators[typingIdx] : noTyping) return (recent ? typingIndicators[typingIdx] : noTyping)
.font(.body.monospaced()) .font(.body.monospaced())
.kerning(-2) .kerning(-2)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true)
} }
} }
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text { func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool) -> Text {
let s = text let s = text
var res: Text var res: Text
if let ft = formattedText, ft.count > 0 && ft.count <= 200 { if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
@@ -103,7 +100,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
} }
if let i = icon { if let i = icon {
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res res = Text(Image(systemName: i)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ") + res
} }
if let s = sender { if let s = sender {
@@ -1,131 +0,0 @@
//
// ChatItemForwardingView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 12.04.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ChatItemForwardingView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss
var ci: ChatItem
var fromChatInfo: ChatInfo
@Binding var composeState: ComposeState
@State private var searchText: String = ""
@FocusState private var searchFocused
@State private var alert: SomeAlert?
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
var body: some View {
NavigationView {
forwardListView()
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .principal) {
Text("Forward")
.bold()
}
}
}
.modifier(ThemedBackground())
.alert(item: $alert) { $0.alert }
}
@ViewBuilder private func forwardListView() -> some View {
VStack(alignment: .leading) {
if !chatsToForwardTo.isEmpty {
List {
searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
ForEach(chats) { chat in
forwardListChatView(chat)
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
}
}
.modifier(ThemedBackground(grouped: true))
} else {
ZStack {
emptyList()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.modifier(ThemedBackground())
}
}
}
private func emptyList() -> some View {
Text("No filtered chats")
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity)
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false
)
Button {
if prohibited {
alert = SomeAlert(
alert: mkAlert(
title: "Cannot forward message",
message: "Selected chat preferences prohibit this message."
),
id: "forward prohibited by preferences"
)
} else {
dismiss()
if chat.id == fromChatInfo.id {
composeState = ComposeState(
message: composeState.message,
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
)
} else {
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id)
}
}
} label: {
HStack {
ChatInfoImage(chat: chat, size: 30)
.padding(.trailing, 2)
Text(chat.chatInfo.chatViewName)
.foregroundColor(prohibited ? theme.colors.secondary : theme.colors.onBackground)
.lineLimit(1)
if chat.chatInfo.incognito {
Spacer()
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.foregroundColor(theme.colors.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
#Preview {
ChatItemForwardingView(
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
fromChatInfo: .direct(contact: Contact.sampleData),
composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme())
}
+35 -162
View File
@@ -11,20 +11,16 @@ import SimpleXChat
struct ChatItemInfoView: View { struct ChatItemInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
var ci: ChatItem var ci: ChatItem
@Binding var chatItemInfo: ChatItemInfo? @Binding var chatItemInfo: ChatItemInfo?
@State private var selection: CIInfoTab = .history @State private var selection: CIInfoTab = .history
@State private var alert: CIInfoViewAlert? = nil @State private var alert: CIInfoViewAlert? = nil
@State private var messageStatusLimited: Bool = true
@State private var fileStatusLimited: Bool = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum CIInfoTab { enum CIInfoTab {
case history case history
case quote case quote
case forwarded
case delivery case delivery
} }
@@ -72,20 +68,9 @@ struct ChatItemInfoView: View {
if ci.quotedItem != nil { if ci.quotedItem != nil {
numTabs += 1 numTabs += 1
} }
if chatItemInfo?.forwardedFromChatItem != nil {
numTabs += 1
}
return numTabs return numTabs
} }
private var local: Bool {
switch ci.chatDir {
case .localSnd: true
case .localRcv: true
default: false
}
}
@ViewBuilder private func itemInfoView() -> some View { @ViewBuilder private func itemInfoView() -> some View {
if numTabs > 1 { if numTabs > 1 {
TabView(selection: $selection) { TabView(selection: $selection) {
@@ -101,22 +86,12 @@ struct ChatItemInfoView: View {
Label("History", systemImage: "clock") Label("History", systemImage: "clock")
} }
.tag(CIInfoTab.history) .tag(CIInfoTab.history)
.modifier(ThemedBackground())
if let qi = ci.quotedItem { if let qi = ci.quotedItem {
quoteTab(qi) quoteTab(qi)
.tabItem { .tabItem {
Label("In reply to", systemImage: "arrowshape.turn.up.left") Label("In reply to", systemImage: "arrowshape.turn.up.left")
} }
.tag(CIInfoTab.quote) .tag(CIInfoTab.quote)
.modifier(ThemedBackground())
}
if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem {
forwardedFromTab(forwardedFromItem)
.tabItem {
Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward")
}
.tag(CIInfoTab.forwarded)
.modifier(ThemedBackground())
} }
} }
.onAppear { .onAppear {
@@ -126,7 +101,6 @@ struct ChatItemInfoView: View {
} }
} else { } else {
historyTab() historyTab()
.modifier(ThemedBackground())
} }
} }
@@ -163,35 +137,6 @@ struct ChatItemInfoView: View {
if developerTools { if developerTools {
infoRow("Database ID", "\(meta.itemId)") infoRow("Database ID", "\(meta.itemId)")
infoRow("Record updated at", localTimestamp(meta.updatedAt)) infoRow("Record updated at", localTimestamp(meta.updatedAt))
let msv = infoRow("Message status", ci.meta.itemStatus.id)
Group {
if messageStatusLimited {
msv.lineLimit(1)
} else {
msv
}
}
.onTapGesture {
withAnimation {
messageStatusLimited.toggle()
}
}
if let file = ci.file {
let fsv = infoRow("File status", file.fileStatus.id)
Group {
if fileStatusLimited {
fsv.lineLimit(1)
} else {
fsv
}
}
.onTapGesture {
withAnimation {
fileStatusLimited.toggle()
}
}
}
} }
} }
} }
@@ -216,7 +161,7 @@ struct ChatItemInfoView: View {
} }
else { else {
Text("No history") Text("No history")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
@@ -231,8 +176,8 @@ struct ChatItemInfoView: View {
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil) textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(ci, theme)) .background(chatItemFrameColor(ci, colorScheme))
.modifier(ChatItemClipped()) .cornerRadius(18)
.contextMenu { .contextMenu {
if itemVersion.msgContent.text != "" { if itemVersion.msgContent.text != "" {
Button { Button {
@@ -262,19 +207,18 @@ struct ChatItemInfoView: View {
} else { } else {
Text("no text") Text("no text")
.italic() .italic()
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
private struct TextBubble: View { private struct TextBubble: View {
@EnvironmentObject var theme: AppTheme
var text: String var text: String
var formattedText: [FormattedText]? var formattedText: [FormattedText]?
var sender: String? = nil var sender: String? = nil
@State private var showSecrets = false @State private var showSecrets = false
var body: some View { var body: some View {
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)) toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets))
} }
} }
@@ -301,8 +245,8 @@ struct ChatItemInfoView: View {
textBubble(qi.text, qi.formattedText, qi.getSender(nil)) textBubble(qi.text, qi.formattedText, qi.getSender(nil))
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, theme)) .background(quotedMsgFrameColor(qi, colorScheme))
.modifier(ChatItemClipped()) .cornerRadius(18)
.contextMenu { .contextMenu {
if qi.text != "" { if qi.text != "" {
Button { Button {
@@ -325,78 +269,10 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading) .frame(maxWidth: maxWidth, alignment: .leading)
} }
func quotedMsgFrameColor(_ qi: CIQuote, _ theme: AppTheme) -> Color { func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
(qi.chatDir?.sent ?? false) (qi.chatDir?.sent ?? false)
? theme.appColors.sentMessage ? (colorScheme == .light ? sentColorLight : sentColorDark)
: theme.appColors.receivedMessage : Color(uiColor: .tertiarySystemGroupedBackground)
}
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
details()
Divider().padding(.vertical)
Text(local ? "Saved from" : "Forwarded from")
.font(.title2)
.padding(.bottom, 4)
forwardedFromView(forwardedFromItem)
}
.padding()
}
.frame(maxHeight: .infinity, alignment: .top)
}
private func forwardedFromView(_ forwardedFromItem: AChatItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
Button {
Task {
await MainActor.run {
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
dismiss()
}
}
} label: {
forwardedFromSender(forwardedFromItem)
}
if !local {
Divider().padding(.top, 32)
Text("Recipient(s) can't see who this message is from.")
.font(.caption)
.foregroundColor(theme.colors.secondary)
}
}
}
@ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
HStack {
ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48)
.padding(.trailing, 6)
if forwardedFromItem.chatItem.chatDir.sent {
VStack(alignment: .leading) {
Text("you")
.italic()
.foregroundColor(theme.colors.onBackground)
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
}
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
VStack(alignment: .leading) {
Text(groupMember.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
}
} else {
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
}
}
} }
@ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
@@ -415,54 +291,55 @@ struct ChatItemInfoView: View {
} }
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
LazyVStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
let mss = membersStatuses(memberDeliveryStatuses) let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty { if !mss.isEmpty {
ForEach(mss, id: \.0.groupMemberId) { memberStatus in ForEach(mss, id: \.0.groupMemberId) { memberStatus in
memberDeliveryStatusView(memberStatus.0, memberStatus.1, memberStatus.2) memberDeliveryStatusView(memberStatus.0, memberStatus.1)
} }
} else { } else {
Text("No delivery information") Text("No delivery information")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
} }
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] { private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
memberDeliveryStatuses.compactMap({ mds in memberDeliveryStatuses.compactMap({ mds in
if let mem = chatModel.getGroupMember(mds.groupMemberId) { if let mem = chatModel.getGroupMember(mds.groupMemberId) {
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy) return (mem.wrapped, mds.memberDeliveryStatus)
} else { } else {
return nil return nil
} }
}) })
} }
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View { private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View {
HStack{ HStack{
MemberProfileImage(member, size: 30) ProfileImage(imageStr: member.image)
.frame(width: 30, height: 30)
.padding(.trailing, 2) .padding(.trailing, 2)
Text(member.chatViewName) Text(member.chatViewName)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer()
if sentViaProxy == true {
Image(systemName: "arrow.forward")
.foregroundColor(theme.colors.secondary).opacity(0.67)
}
let v = Group { let v = Group {
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) if let (icon, statusColor) = status.statusIcon(Color.secondary) {
switch status { switch status {
case .rcvd: case .sndRcvd:
ZStack(alignment: .trailing) { 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) Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67)) .foregroundColor(statusColor)
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
} }
default: } else {
Image(systemName: icon) Image(systemName: "ellipsis")
.foregroundColor(statusColor) .foregroundColor(Color.secondary)
} }
} }
@@ -505,12 +382,8 @@ struct ChatItemInfoView: View {
if developerTools { if developerTools {
shareText += [ shareText += [
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId), String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt)), String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt))
String.localizedStringWithFormat(NSLocalizedString("Message status: %@", comment: "copied message info"), meta.itemStatus.id)
] ]
if let file = ci.file {
shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)]
}
} }
if let qi = ci.quotedItem { if let qi = ci.quotedItem {
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")] shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
+37 -53
View File
@@ -11,36 +11,46 @@ import SimpleXChat
struct ChatItemView: View { struct ChatItemView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem var chatItem: ChatItem
var maxWidth: CGFloat = .infinity var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@Binding var revealed: Bool @Binding var revealed: Bool
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
init( init(
chat: Chat, chat: Chat,
chatItem: ChatItem, chatItem: ChatItem,
showMember: Bool = false, showMember: Bool = false,
maxWidth: CGFloat = .infinity, maxWidth: CGFloat = .infinity,
scrollProxy: ScrollViewProxy? = nil,
revealed: Binding<Bool>, revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false) allowMenu: Binding<Bool> = .constant(false),
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
playbackTime: Binding<TimeInterval?> = .constant(nil)
) { ) {
self.chat = chat self.chat = chat
self.chatItem = chatItem self.chatItem = chatItem
self.maxWidth = maxWidth self.maxWidth = maxWidth
_scrollProxy = .init(initialValue: scrollProxy)
_revealed = revealed _revealed = revealed
_allowMenu = allowMenu _allowMenu = allowMenu
_audioPlayer = audioPlayer
_playbackState = playbackState
_playbackTime = playbackTime
} }
var body: some View { var body: some View {
let ci = chatItem let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed) MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { } else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci) EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu) CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil { } else if ci.content.msgContent == nil {
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else { } else {
@@ -52,38 +62,12 @@ struct ChatItemView: View {
} }
private func framedItemView() -> some View { private func framedItemView() -> some View {
let preview = chatItem.content.msgContent FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.flatMap {
switch $0 {
case let .image(_, image): image
case let .video(_, image, _): image
default: nil
}
}
.flatMap { UIImage(base64Encoded: $0) }
let adjustedMaxWidth = {
if let preview, preview.size.width <= preview.size.height {
maxWidth * 0.75
} else {
maxWidth
}
}()
return FramedItemView(
chat: chat,
chatItem: chatItem,
preview: preview,
revealed: $revealed,
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth,
allowMenu: $allowMenu
)
} }
} }
struct ChatItemContentView<Content: View>: View { struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool @Binding var revealed: Bool
@@ -113,14 +97,14 @@ struct ChatItemContentView<Content: View>: View {
case .sndGroupEvent: eventItemView() case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView() case .rcvConnEvent: eventItemView()
case .sndConnEvent: eventItemView() case .sndConnEvent: eventItemView()
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary)) case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary)) case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .rcvChatPreference(feature, allowed, param): case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _): case let .sndChatPreference(feature, _, _):
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary) CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red) case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
case .sndModerated: deletedItemView() case .sndModerated: deletedItemView()
@@ -143,29 +127,29 @@ struct ChatItemContentView<Content: View>: View {
} }
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View { private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
CIGroupInvitationView(chat: chat, chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito) CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito)
} }
private func eventItemView() -> some View { private func eventItemView() -> some View {
CIEventView(eventText: eventItemViewText(theme.colors.secondary)) return CIEventView(eventText: eventItemViewText())
} }
private func eventItemViewText(_ secondaryColor: Color) -> Text { private func eventItemViewText() -> Text {
if !revealed, let t = mergedGroupEventText { if !revealed, let t = mergedGroupEventText {
return chatEventText(t + Text(" ") + chatItem.timestampText, secondaryColor) return chatEventText(t + Text(" ") + chatItem.timestampText)
} else if let member = chatItem.memberDisplayName { } else if let member = chatItem.memberDisplayName {
return Text(member + " ") return Text(member + " ")
.font(.caption) .font(.caption)
.foregroundColor(secondaryColor) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
+ chatEventText(chatItem, secondaryColor) + chatEventText(chatItem)
} else { } else {
return chatEventText(chatItem, secondaryColor) return chatEventText(chatItem)
} }
} }
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor) CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
} }
private var mergedGroupEventText: Text? { private var mergedGroupEventText: Text? {
@@ -195,7 +179,7 @@ struct ChatItemContentView<Content: View>: View {
info.pqEnabled info.pqEnabled
? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.") ? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
: e2eeInfoNoPQText() : e2eeInfoNoPQText()
} }
@@ -203,24 +187,24 @@ struct ChatItemContentView<Content: View>: View {
private func e2eeInfoNoPQText() -> Text { private func e2eeInfoNoPQText() -> Text {
Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.") Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
} }
} }
func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text { func chatEventText(_ text: Text) -> Text {
text text
.font(.caption) .font(.caption)
.foregroundColor(secondaryColor) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
} }
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text { func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor) chatEventText(Text(eventText) + Text(" ") + ts)
} }
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { func chatEventText(_ ci: ChatItem) -> Text {
chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor) chatEventText("\(ci.content.text)", ci.timestampText)
} }
struct ChatItemView_Previews: PreviewProvider { struct ChatItemView_Previews: PreviewProvider {
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@
import SwiftUI import SwiftUI
struct ComposeFileView: View { struct ComposeFileView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let fileName: String let fileName: String
let cancelFile: (() -> Void) let cancelFile: (() -> Void)
let cancelEnabled: Bool let cancelEnabled: Bool
@@ -32,8 +32,9 @@ struct ComposeFileView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.padding(.trailing, 12) .padding(.trailing, 12)
.frame(height: 54) .frame(height: 50)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
} }
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ComposeImageView: View { struct ComposeImageView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let images: [String] let images: [String]
let cancelImage: (() -> Void) let cancelImage: (() -> Void)
let cancelEnabled: Bool let cancelEnabled: Bool
@@ -18,7 +18,10 @@ struct ComposeImageView: View {
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
let imgs: [UIImage] = images.compactMap { image in let imgs: [UIImage] = images.compactMap { image in
UIImage(base64Encoded: image) if let data = Data(base64Encoded: dropImagePrefix(image)) {
return UIImage(data: data)
}
return nil
} }
if imgs.count == 0 { if imgs.count == 0 {
ProgressView() ProgressView()
@@ -45,9 +48,9 @@ struct ComposeImageView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.padding(.trailing, 12) .padding(.trailing, 12)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
} }
@@ -10,8 +10,37 @@ import SwiftUI
import LinkPresentation import LinkPresentation
import SimpleXChat import SimpleXChat
func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
cb(nil)
}
}
}
struct ComposeLinkView: View { struct ComposeLinkView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
let linkPreview: LinkPreview? let linkPreview: LinkPreview?
var cancelPreview: (() -> Void)? = nil var cancelPreview: (() -> Void)? = nil
let cancelEnabled: Bool let cancelEnabled: Bool
@@ -33,14 +62,15 @@ struct ComposeLinkView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.padding(.trailing, 12) .padding(.trailing, 12)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View { private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) { if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
@@ -52,10 +82,10 @@ struct ComposeLinkView: View {
Text(linkPreview.uri.absoluteString) Text(linkPreview.uri.absoluteString)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
.padding(.vertical, 5) .padding(.vertical, 5)
.frame(maxWidth: .infinity, minHeight: 60) .frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
} }
} }
} }
@@ -23,7 +23,6 @@ enum ComposeContextItem {
case noContextItem case noContextItem
case quotedItem(chatItem: ChatItem) case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem)
case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo)
} }
enum VoiceMessageRecordingState { enum VoiceMessageRecordingState {
@@ -73,13 +72,6 @@ struct ComposeState {
} }
} }
init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) {
self.message = ""
self.preview = .noPreview
self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo)
self.voiceMessageRecordingState = .noRecording
}
func copy( func copy(
message: String? = nil, message: String? = nil,
liveMessage: LiveMessage? = nil, liveMessage: LiveMessage? = nil,
@@ -110,19 +102,12 @@ struct ComposeState {
} }
} }
var forwarding: Bool {
switch contextItem {
case .forwardingItem: return true
default: return false
}
}
var sendEnabled: Bool { var sendEnabled: Bool {
switch preview { switch preview {
case let .mediaPreviews(media): return !media.isEmpty case let .mediaPreviews(media): return !media.isEmpty
case .voicePreview: return voiceMessageRecordingState == .finished case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true case .filePreview: return true
default: return !message.isEmpty || forwarding || liveMessage != nil default: return !message.isEmpty || liveMessage != nil
} }
} }
@@ -168,7 +153,7 @@ struct ComposeState {
} }
var attachmentDisabled: Bool { var attachmentDisabled: Bool {
if editing || forwarding || liveMessage != nil || inProgress { return true } if editing || liveMessage != nil || inProgress { return true }
switch preview { switch preview {
case .noPreview: return false case .noPreview: return false
case .linkPreview: return false case .linkPreview: return false
@@ -176,16 +161,6 @@ struct ComposeState {
} }
} }
var attachmentPreview: Bool {
switch preview {
case .noPreview: false
case .linkPreview: false
case let .mediaPreviews(mediaPreviews): !mediaPreviews.isEmpty
case .voicePreview: false
case .filePreview: true
}
}
var empty: Bool { var empty: Bool {
message == "" && noPreview message == "" && noPreview
} }
@@ -254,13 +229,11 @@ enum UploadContent: Equatable {
struct ComposeView: View { struct ComposeView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@Binding var keyboardVisible: Bool @Binding var keyboardVisible: Bool
@State var linkUrl: URL? = nil @State var linkUrl: URL? = nil
@State var hasSimplexLink: Bool = false
@State var prevLinkUrl: URL? = nil @State var prevLinkUrl: URL? = nil
@State var pendingLinkUrl: URL? = nil @State var pendingLinkUrl: URL? = nil
@State var cancelledLinks: Set<String> = [] @State var cancelledLinks: Set<String> = []
@@ -281,28 +254,11 @@ struct ComposeView: View {
@State private var stopPlayback: Bool = false @State private var stopPlayback: Bool = false
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Divider()
if chat.chatInfo.contact?.nextSendGrpInv ?? false { if chat.chatInfo.contact?.nextSendGrpInv ?? false {
ContextInvitingContactMemberView() ContextInvitingContactMemberView()
Divider()
}
// preference checks should match checks in forwarding list
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
if simplexLinkProhibited {
msgNotAllowedView("SimpleX links not allowed", icon: "link")
Divider()
} else if fileProhibited {
msgNotAllowedView("Files and media not allowed", icon: "doc")
Divider()
} else if voiceProhibited {
msgNotAllowedView("Voice messages not allowed", icon: "mic")
Divider()
} }
contextItemView() contextItemView()
switch (composeState.editing, composeState.preview) { switch (composeState.editing, composeState.preview) {
@@ -321,9 +277,8 @@ struct ComposeView: View {
.frame(width: 25, height: 25) .frame(width: 25, height: 25)
.padding(.bottom, 12) .padding(.bottom, 12)
.padding(.leading, 12) .padding(.leading, 12)
.tint(theme.colors.primary)
if case let .group(g) = chat.chatInfo, if case let .group(g) = chat.chatInfo,
!g.fullGroupPreferences.files.on(for: g.membership) { !g.fullGroupPreferences.files.on {
b.disabled(true).onTapGesture { b.disabled(true).onTapGesture {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Files and media prohibited!", title: "Files and media prohibited!",
@@ -348,7 +303,6 @@ struct ComposeView: View {
}, },
nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false, nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
disableSendButton: simplexLinkProhibited || fileProhibited || voiceProhibited,
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
startVoiceMessageRecording: { startVoiceMessageRecording: {
Task { Task {
@@ -362,15 +316,16 @@ struct ComposeView: View {
keyboardVisible: $keyboardVisible, keyboardVisible: $keyboardVisible,
sendButtonColor: chat.chatInfo.incognito sendButtonColor: chat.chatInfo.incognito
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7) ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
: theme.colors.primary : .accentColor
) )
.padding(.trailing, 12) .padding(.trailing, 12)
.background(.background)
.disabled(!chat.userCanSend) .disabled(!chat.userCanSend)
if chat.userIsObserver { if chat.userIsObserver {
Text("you are observer") Text("you are observer")
.italic() .italic()
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.onTapGesture { .onTapGesture {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -382,23 +337,13 @@ struct ComposeView: View {
} }
} }
} }
.background { .onChange(of: composeState.message) { _ in
Color.clear
.overlay(ToolbarMaterial.material(toolbarMaterial))
.ignoresSafeArea(.all, edges: .bottom)
}
.onChange(of: composeState.message) { msg in
if composeState.linkPreviewAllowed { if composeState.linkPreviewAllowed {
if msg.count > 0 { if composeState.message.count > 0 {
showLinkPreview(msg) showLinkPreview(composeState.message)
} else { } else {
resetLinkPreview() resetLinkPreview()
hasSimplexLink = false
} }
} else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
(_, hasSimplexLink) = parseMessage(msg)
} else {
hasSimplexLink = false
} }
} }
.onChange(of: chat.userCanSend) { canSend in .onChange(of: chat.userCanSend) { canSend in
@@ -635,7 +580,6 @@ struct ComposeView: View {
cancelPreview: cancelLinkPreview, cancelPreview: cancelLinkPreview,
cancelEnabled: !composeState.inProgress cancelEnabled: !composeState.inProgress
) )
Divider()
case let .mediaPreviews(mediaPreviews: media): case let .mediaPreviews(mediaPreviews: media):
ComposeImageView( ComposeImageView(
images: media.map { (img, _) in img }, images: media.map { (img, _) in img },
@@ -644,7 +588,6 @@ struct ComposeView: View {
chosenMedia = [] chosenMedia = []
}, },
cancelEnabled: !composeState.editing && !composeState.inProgress) cancelEnabled: !composeState.editing && !composeState.inProgress)
Divider()
case let .voicePreview(recordingFileName, _): case let .voicePreview(recordingFileName, _):
ComposeVoiceView( ComposeVoiceView(
recordingFileName: recordingFileName, recordingFileName: recordingFileName,
@@ -657,7 +600,6 @@ struct ComposeView: View {
cancelEnabled: !composeState.editing && !composeState.inProgress, cancelEnabled: !composeState.editing && !composeState.inProgress,
stopPlayback: $stopPlayback stopPlayback: $stopPlayback
) )
Divider()
case let .filePreview(fileName, _): case let .filePreview(fileName, _):
ComposeFileView( ComposeFileView(
fileName: fileName, fileName: fileName,
@@ -665,21 +607,9 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview) composeState = composeState.copy(preview: .noPreview)
}, },
cancelEnabled: !composeState.editing && !composeState.inProgress) cancelEnabled: !composeState.editing && !composeState.inProgress)
Divider()
} }
} }
private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
HStack {
Image(systemName: icon).foregroundColor(theme.colors.secondary)
Text(reason).italic()
}
.padding(12)
.frame(minHeight: 54)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
}
@ViewBuilder private func contextItemView() -> some View { @ViewBuilder private func contextItemView() -> some View {
switch composeState.contextItem { switch composeState.contextItem {
case .noContextItem: case .noContextItem:
@@ -691,7 +621,6 @@ struct ComposeView: View {
contextIcon: "arrowshape.turn.up.left", contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
) )
Divider()
case let .editingItem(chatItem: editingItem): case let .editingItem(chatItem: editingItem):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
@@ -699,16 +628,6 @@ struct ComposeView: View {
contextIcon: "pencil", contextIcon: "pencil",
cancelContextItem: { clearState() } cancelContextItem: { clearState() }
) )
Divider()
case let .forwardingItem(chatItem: forwardedItem, _):
ContextItemView(
chat: chat,
contextItem: forwardedItem,
contextIcon: "arrowshape.turn.up.forward",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
showSender: false
)
Divider()
} }
} }
@@ -730,11 +649,6 @@ struct ComposeView: View {
} }
if chat.chatInfo.contact?.nextSendGrpInv ?? false { if chat.chatInfo.contact?.nextSendGrpInv ?? false {
await sendMemberContactInvitation() await sendMemberContactInvitation()
} else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem {
sent = await forwardItem(ci, fromChatInfo, ttl)
if !composeState.message.isEmpty {
sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
}
} else if case let .editingItem(ci) = composeState.contextItem { } else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live) sent = await updateMessage(ci, live: live)
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
@@ -780,15 +694,7 @@ struct ComposeView: View {
} }
} }
} }
await MainActor.run { await MainActor.run { clearState(live: live) }
let wasForwarding = composeState.forwarding
clearState(live: live)
if wasForwarding,
chatModel.draftChatId == chat.chatInfo.id,
let draft = chatModel.draft {
composeState = draft
}
}
return sent return sent
func sending() async { func sending() async {
@@ -865,7 +771,6 @@ struct ComposeView: View {
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData let (image, data) = imageData
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) { if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
ChatModel.shared.filesToDelete.remove(url)
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl) return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
} }
return nil return nil
@@ -910,27 +815,10 @@ struct ComposeView: View {
return nil return nil
} }
func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
if let chatItem = await apiForwardChatItem(
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
itemId: forwardedItem.id,
ttl: ttl
) {
await MainActor.run {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
return chatItem
}
return nil
}
func checkLinkPreview() -> MsgContent { func checkLinkPreview() -> MsgContent {
switch (composeState.preview) { switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview): case let .linkPreview(linkPreview: linkPreview):
if let url = parseMessage(msgText).url, if let url = parseMessage(msgText),
let linkPreview = linkPreview, let linkPreview = linkPreview,
url == linkPreview.uri { url == linkPreview.uri {
return .link(text: msgText, preview: linkPreview) return .link(text: msgText, preview: linkPreview)
@@ -1059,7 +947,7 @@ struct ComposeView: View {
private func showLinkPreview(_ s: String) { private func showLinkPreview(_ s: String) {
prevLinkUrl = linkUrl prevLinkUrl = linkUrl
(linkUrl, hasSimplexLink) = parseMessage(s) linkUrl = parseMessage(s)
if let url = linkUrl { if let url = linkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl { if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
pendingLinkUrl = url pendingLinkUrl = url
@@ -1076,17 +964,13 @@ struct ComposeView: View {
} }
} }
private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) { private func parseMessage(_ msg: String) -> URL? {
guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) } let parsedMsg = parseSimpleXMarkdown(msg)
let url: URL? = if let uri = parsedMsg.first(where: { ft in let uri = parsedMsg?.first(where: { ft in
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
}) { })
URL(string: uri.text) if let uri = uri { return URL(string: uri.text) }
} else { else { return nil }
nil
}
let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
return (url, simplexLink)
} }
private func isSimplexLink(_ link: String) -> Bool { private func isSimplexLink(_ link: String) -> Bool {
@@ -25,7 +25,7 @@ func voiceMessageTime_(_ time: TimeInterval?) -> String {
struct ComposeVoiceView: View { struct ComposeVoiceView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
var recordingFileName: String var recordingFileName: String
@Binding var recordingTime: TimeInterval? @Binding var recordingTime: TimeInterval?
@Binding var recordingState: VoiceMessageRecordingState @Binding var recordingState: VoiceMessageRecordingState
@@ -50,9 +50,9 @@ struct ComposeVoiceView: View {
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.frame(height: ComposeVoiceView.previewHeight) .frame(height: ComposeVoiceView.previewHeight)
.background(theme.appColors.sentMessage) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, 8)
} }
private func recordingMode() -> some View { private func recordingMode() -> some View {
@@ -80,7 +80,7 @@ struct ComposeVoiceView: View {
Button { Button {
startPlayback() startPlayback()
} label: { } label: {
playPauseIcon("play.fill", theme.colors.primary) playPauseIcon("play.fill")
} }
Text(voiceMessageTime_(recordingTime)) Text(voiceMessageTime_(recordingTime))
case .playing: case .playing:
@@ -88,7 +88,7 @@ struct ComposeVoiceView: View {
audioPlayer?.pause() audioPlayer?.pause()
playbackState = .paused playbackState = .paused
} label: { } label: {
playPauseIcon("pause.fill", theme.colors.primary) playPauseIcon("pause.fill")
} }
Text(voiceMessageTime_(playbackTime)) Text(voiceMessageTime_(playbackTime))
case .paused: case .paused:
@@ -96,7 +96,7 @@ struct ComposeVoiceView: View {
audioPlayer?.play() audioPlayer?.play()
playbackState = .playing playbackState = .playing
} label: { } label: {
playPauseIcon("play.fill", theme.colors.primary) playPauseIcon("play.fill")
} }
Text(voiceMessageTime_(playbackTime)) Text(voiceMessageTime_(playbackTime))
} }
@@ -131,7 +131,7 @@ struct ComposeVoiceView: View {
} }
} }
private func playPauseIcon(_ image: String, _ color: Color) -> some View { private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
@@ -147,11 +147,9 @@ struct ComposeVoiceView: View {
} label: { } label: {
Image(systemName: "multiply") Image(systemName: "multiply")
} }
.tint(theme.colors.primary)
} }
struct SliderBar: View { struct SliderBar: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval var length: TimeInterval
@Binding var progress: TimeInterval? @Binding var progress: TimeInterval?
var seek: (TimeInterval) -> Void var seek: (TimeInterval) -> Void
@@ -160,12 +158,10 @@ struct ComposeVoiceView: View {
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length) Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 4) .frame(height: 4)
.tint(theme.colors.primary)
} }
} }
private struct ProgressBar: View { private struct ProgressBar: View {
@EnvironmentObject var theme: AppTheme
var length: TimeInterval var length: TimeInterval
@Binding var progress: TimeInterval? @Binding var progress: TimeInterval?
@@ -173,7 +169,7 @@ struct ComposeVoiceView: View {
GeometryReader { geometry in GeometryReader { geometry in
ZStack { ZStack {
Rectangle() Rectangle()
.fill(theme.colors.primary) .fill(Color.accentColor)
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4) .frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
.animation(.linear, value: progress) .animation(.linear, value: progress)
} }
@@ -9,18 +9,19 @@
import SwiftUI import SwiftUI
struct ContextInvitingContactMemberView: View { struct ContextInvitingContactMemberView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
var body: some View { var body: some View {
HStack { HStack {
Image(systemName: "message") Image(systemName: "message")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
Text("Send direct message to connect") Text("Send direct message to connect")
} }
.padding(12) .padding(12)
.frame(minHeight: 54) .frame(minHeight: 50)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial) .background(colorScheme == .light ? sentColorLight : sentColorDark)
.padding(.top, 8)
} }
} }
@@ -10,12 +10,11 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ContextItemView: View { struct ContextItemView: View {
@EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
let contextItem: ChatItem let contextItem: ChatItem
let contextIcon: String let contextIcon: String
let cancelContextItem: () -> Void let cancelContextItem: () -> Void
var showSender: Bool = true
var body: some View { var body: some View {
HStack { HStack {
@@ -23,10 +22,10 @@ struct ContextItemView: View {
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
if showSender, let sender = contextItem.memberDisplayName { if let sender = contextItem.memberDisplayName {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(sender).font(.caption).foregroundColor(theme.colors.secondary) Text(sender).font(.caption).foregroundColor(.secondary)
msgContentView(lines: 2) msgContentView(lines: 2)
} }
} else { } else {
@@ -40,35 +39,23 @@ struct ContextItemView: View {
} label: { } label: {
Image(systemName: "multiply") Image(systemName: "multiply")
} }
.tint(theme.colors.primary)
} }
.padding(12) .padding(12)
.frame(minHeight: 54) .frame(minHeight: 50)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(chatItemFrameColor(contextItem, theme)) .background(chatItemFrameColor(contextItem, colorScheme))
.padding(.top, 8)
} }
private func msgContentView(lines: Int) -> some View { private func msgContentView(lines: Int) -> some View {
contextMsgPreview() MsgContentView(
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading) chat: chat,
.lineLimit(lines) text: contextItem.text,
} formattedText: contextItem.formattedText,
showSecrets: false
private func contextMsgPreview() -> Text { )
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
.lineLimit(lines)
func attachment() -> Text {
switch contextItem.content.msgContent {
case .file: return image("doc.fill")
case .image: return image("photo")
case .voice: return image("play.fill")
default: return Text("")
}
}
func image(_ s: String) -> Text {
Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ")
}
} }
} }
@@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable {
@Binding var disableEditing: Bool @Binding var disableEditing: Bool
@Binding var height: CGFloat @Binding var height: CGFloat
@Binding var focused: Bool @Binding var focused: Bool
let alignment: TextAlignment
let onImagesAdded: ([UploadContent]) -> Void let onImagesAdded: ([UploadContent]) -> Void
private let minHeight: CGFloat = 37 private let minHeight: CGFloat = 37
@@ -28,18 +29,14 @@ struct NativeTextEditor: UIViewRepresentable {
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> UITextView {
let field = CustomUITextField(height: _height) let field = CustomUITextField(height: _height)
field.backgroundColor = .clear
field.text = text field.text = text
field.textAlignment = alignment(text) field.textAlignment = alignment == .leading ? .left : .right
field.autocapitalizationType = .sentences field.autocapitalizationType = .sentences
field.setOnTextChangedListener { newText, images in field.setOnTextChangedListener { newText, images in
if !disableEditing { if !disableEditing {
text = newText
field.textAlignment = alignment(text)
updateFont(field)
// Speed up the process of updating layout, reduce jumping content on screen // Speed up the process of updating layout, reduce jumping content on screen
updateHeight(field) if !isShortEmoji(newText) { updateHeight(field) }
self.height = field.frame.size.height text = newText
} else { } else {
field.text = text field.text = text
} }
@@ -56,12 +53,10 @@ struct NativeTextEditor: UIViewRepresentable {
} }
func updateUIView(_ field: UITextView, context: Context) { func updateUIView(_ field: UITextView, context: Context) {
if field.markedTextRange == nil && field.text != text { field.text = text
field.text = text field.textAlignment = alignment == .leading ? .left : .right
field.textAlignment = alignment(text) updateFont(field)
updateFont(field) updateHeight(field)
updateHeight(field)
}
} }
private func updateHeight(_ field: UITextView) { private func updateHeight(_ field: UITextView) {
@@ -78,19 +73,12 @@ struct NativeTextEditor: UIViewRepresentable {
} }
private func updateFont(_ field: UITextView) { private func updateFont(_ field: UITextView) {
let newFont = isShortEmoji(field.text) field.font = isShortEmoji(field.text)
? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont) ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
: UIFont.preferredFont(forTextStyle: .body) : UIFont.preferredFont(forTextStyle: .body)
if field.font != newFont {
field.font = newFont
}
} }
} }
private func alignment(_ text: String) -> NSTextAlignment {
isRightToLeft(text) ? .right : .left
}
private class CustomUITextField: UITextView, UITextViewDelegate { private class CustomUITextField: UITextView, UITextViewDelegate {
var height: Binding<CGFloat> var height: Binding<CGFloat>
var newHeight: CGFloat = 0 var newHeight: CGFloat = 0
@@ -217,6 +205,7 @@ struct NativeTextEditor_Previews: PreviewProvider{
disableEditing: Binding.constant(false), disableEditing: Binding.constant(false),
height: Binding.constant(100), height: Binding.constant(100),
focused: Binding.constant(false), focused: Binding.constant(false),
alignment: TextAlignment.leading,
onImagesAdded: { _ in } onImagesAdded: { _ in }
) )
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -13,7 +13,6 @@ private let liveMsgInterval: UInt64 = 3000_000000
struct SendMessageView: View { struct SendMessageView: View {
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@EnvironmentObject var theme: AppTheme
var sendMessage: (Int?) -> Void var sendMessage: (Int?) -> Void
var sendLiveMessage: (() async -> Void)? = nil var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil
@@ -21,7 +20,6 @@ struct SendMessageView: View {
var nextSendGrpInv: Bool = false var nextSendGrpInv: Bool = false
var showVoiceMessageButton: Bool = true var showVoiceMessageButton: Bool = true
var voiceMessageAllowed: Bool = true var voiceMessageAllowed: Bool = true
var disableSendButton = false
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
var startVoiceMessageRecording: (() -> Void)? = nil var startVoiceMessageRecording: (() -> Void)? = nil
var finishVoiceMessageRecording: (() -> Void)? = nil var finishVoiceMessageRecording: (() -> Void)? = nil
@@ -44,29 +42,31 @@ struct SendMessageView: View {
var body: some View { var body: some View {
ZStack { ZStack {
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
HStack(alignment: .bottom) { HStack(alignment: .bottom) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
if case .voicePreview = composeState.preview { if case .voicePreview = composeState.preview {
Text("Voice message…") Text("Voice message…")
.font(teFont.italic()) .font(teFont.italic())
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 8) .padding(.vertical, 8)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} else { } else {
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
NativeTextEditor( NativeTextEditor(
text: $composeState.message, text: $composeState.message,
disableEditing: $composeState.inProgress, disableEditing: $composeState.inProgress,
height: $teHeight, height: $teHeight,
focused: $keyboardVisible, focused: $keyboardVisible,
alignment: alignment,
onImagesAdded: onMediaAdded onImagesAdded: onMediaAdded
) )
.allowsTightening(false) .allowsTightening(false)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
if progressByTimeout { if progressByTimeout {
ProgressView() ProgressView()
.scaleEffect(1.4) .scaleEffect(1.4)
@@ -84,9 +84,10 @@ struct SendMessageView: View {
} }
} }
.padding(.vertical, 1) .padding(.vertical, 1)
.background(theme.colors.background) .overlay(
.clipShape(composeShape) RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7)) .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
)
} }
.onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in .onChange(of: composeState.inProgress) { inProgress in
@@ -108,7 +109,6 @@ struct SendMessageView: View {
} else if showVoiceMessageButton } else if showVoiceMessageButton
&& composeState.message.isEmpty && composeState.message.isEmpty
&& !composeState.editing && !composeState.editing
&& !composeState.forwarding
&& composeState.liveMessage == nil && composeState.liveMessage == nil
&& ((composeState.noPreview && vmrs == .noRecording) && ((composeState.noPreview && vmrs == .noRecording)
|| (vmrs == .recording && holdingVMR)) { || (vmrs == .recording && holdingVMR)) {
@@ -184,8 +184,7 @@ struct SendMessageView: View {
!composeState.sendEnabled || !composeState.sendEnabled ||
composeState.inProgress || composeState.inProgress ||
(!voiceMessageAllowed && composeState.voicePreview) || (!voiceMessageAllowed && composeState.voicePreview) ||
composeState.endLiveDisabled || composeState.endLiveDisabled
disableSendButton
) )
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
.contextMenu{ .contextMenu{
@@ -247,7 +246,6 @@ struct SendMessageView: View {
} }
private struct RecordVoiceMessageButton: View { private struct RecordVoiceMessageButton: View {
@EnvironmentObject var theme: AppTheme
var startVoiceMessageRecording: (() -> Void)? var startVoiceMessageRecording: (() -> Void)?
var finishVoiceMessageRecording: (() -> Void)? var finishVoiceMessageRecording: (() -> Void)?
@Binding var holdingVMR: Bool @Binding var holdingVMR: Bool
@@ -257,10 +255,7 @@ struct SendMessageView: View {
var body: some View { var body: some View {
Button(action: {}) { Button(action: {}) {
Image(systemName: "mic.fill") Image(systemName: "mic.fill")
.resizable() .foregroundColor(.accentColor)
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.primary)
} }
.disabled(disabled) .disabled(disabled)
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@@ -313,10 +308,7 @@ struct SendMessageView: View {
} }
} label: { } label: {
Image(systemName: "mic") Image(systemName: "mic")
.resizable() .foregroundColor(.secondary)
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.secondary)
} }
.disabled(composeState.inProgress) .disabled(composeState.inProgress)
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@@ -330,7 +322,7 @@ struct SendMessageView: View {
Image(systemName: "multiply") Image(systemName: "multiply")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
.frame(width: 15, height: 15) .frame(width: 15, height: 15)
} }
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@@ -347,7 +339,7 @@ struct SendMessageView: View {
Image(systemName: "bolt.fill") Image(systemName: "bolt.fill")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
} }
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@@ -390,7 +382,7 @@ struct SendMessageView: View {
} }
Task { Task {
_ = try? await Task.sleep(nanoseconds: liveMsgInterval) _ = try? await Task.sleep(nanoseconds: liveMsgInterval)
while await composeState.liveMessage != nil { while composeState.liveMessage != nil {
await update() await update()
_ = try? await Task.sleep(nanoseconds: liveMsgInterval) _ = try? await Task.sleep(nanoseconds: liveMsgInterval)
} }
@@ -401,7 +393,7 @@ struct SendMessageView: View {
private func finishVoiceMessageRecordingButton() -> some View { private func finishVoiceMessageRecordingButton() -> some View {
Button(action: { finishVoiceMessageRecording?() }) { Button(action: { finishVoiceMessageRecording?() }) {
Image(systemName: "stop.fill") Image(systemName: "stop.fill")
.foregroundColor(theme.colors.primary) .foregroundColor(.accentColor)
} }
.disabled(composeState.inProgress) .disabled(composeState.inProgress)
.frame(width: 29, height: 29) .frame(width: 29, height: 29)
@@ -12,7 +12,6 @@ import SimpleXChat
struct ContactPreferencesView: View { struct ContactPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var contact: Contact @Binding var contact: Contact
@State var featuresAllowed: ContactFeaturesAllowed @State var featuresAllowed: ContactFeaturesAllowed
@State var currentFeaturesAllowed: ContactFeaturesAllowed @State var currentFeaturesAllowed: ContactFeaturesAllowed
@@ -67,8 +66,8 @@ struct ContactPreferencesView: View {
.frame(height: 36) .frame(height: 36)
infoRow("Contact allows", pref.contactPreference.allow.text) infoRow("Contact allows", pref.contactPreference.allow.text)
} }
header: { featureHeader(feature, enabled).foregroundColor(theme.colors.secondary) } header: { featureHeader(feature, enabled) }
footer: { featureFooter(feature, enabled).foregroundColor(theme.colors.secondary) } footer: { featureFooter(feature, enabled) }
} }
private func timedMessagesFeatureSection() -> some View { private func timedMessagesFeatureSection() -> some View {
@@ -103,8 +102,8 @@ struct ContactPreferencesView: View {
infoRow("Delete after", timeText(pref.contactPreference.ttl)) infoRow("Delete after", timeText(pref.contactPreference.ttl))
} }
} }
header: { featureHeader(.timedMessages, enabled).foregroundColor(theme.colors.secondary) } header: { featureHeader(.timedMessages, enabled) }
footer: { featureFooter(.timedMessages, enabled).foregroundColor(theme.colors.secondary) } footer: { featureFooter(.timedMessages, enabled) }
} }
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View { private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
@@ -21,7 +21,6 @@ struct AddGroupMembersView: View {
struct AddGroupMembersViewCommon: View { struct AddGroupMembersViewCommon: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
var chat: Chat var chat: Chat
@State var groupInfo: GroupInfo @State var groupInfo: GroupInfo
var creatingGroup: Bool = false var creatingGroup: Bool = false
@@ -35,7 +34,7 @@ struct AddGroupMembersViewCommon: View {
private enum AddGroupMembersAlert: Identifiable { private enum AddGroupMembersAlert: Identifiable {
case prohibitedToInviteIncognito case prohibitedToInviteIncognito
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String { var id: String {
switch self { switch self {
@@ -47,13 +46,14 @@ struct AddGroupMembersViewCommon: View {
var body: some View { var body: some View {
if creatingGroup { if creatingGroup {
addGroupMembersView() NavigationView {
.navigationBarBackButtonHidden() addGroupMembersView()
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button ("Skip") { addedMembersCb(selectedContacts) } Button ("Skip") { addedMembersCb(selectedContacts) }
}
} }
} }
} else { } else {
addGroupMembersView() addGroupMembersView()
} }
@@ -70,7 +70,7 @@ struct AddGroupMembersViewCommon: View {
if (membersToAdd.isEmpty) { if (membersToAdd.isEmpty) {
Text("No contacts to add") Text("No contacts to add")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
@@ -90,18 +90,16 @@ struct AddGroupMembersViewCommon: View {
Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) } Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) }
Spacer() Spacer()
Text("\(count) contact(s) selected") Text("\(count) contact(s) selected")
.foregroundColor(theme.colors.secondary)
} }
} else { } else {
Text("No contacts selected") Text("No contacts selected")
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
.foregroundColor(theme.colors.secondary)
} }
} }
} }
Section { Section {
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.primary, theme.colors.secondary) searchFieldView(text: $searchText, focussed: $searchFocussed)
.padding(.leading, 2) .padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) } let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
@@ -121,13 +119,12 @@ struct AddGroupMembersViewCommon: View {
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile") message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
) )
case let .error(title, error): case let .error(title, error):
return mkAlert(title: title, message: error) return Alert(title: Text(title), message: Text(error))
} }
} }
.onChange(of: selectedContacts) { _ in .onChange(of: selectedContacts) { _ in
searchFocussed = false searchFocussed = false
} }
.modifier(ThemedBackground(grouped: true))
} }
private func inviteMembersButton() -> some View { private func inviteMembersButton() -> some View {
@@ -175,14 +172,14 @@ struct AddGroupMembersViewCommon: View {
var iconColor: Color var iconColor: Color
if prohibitedToInviteIncognito { if prohibitedToInviteIncognito {
icon = "theatermasks.circle.fill" icon = "theatermasks.circle.fill"
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme) iconColor = Color(uiColor: .tertiaryLabel)
} else { } else {
if checked { if checked {
icon = "checkmark.circle.fill" icon = "checkmark.circle.fill"
iconColor = theme.colors.primary iconColor = .accentColor
} else { } else {
icon = "circle" icon = "circle"
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme) iconColor = Color(uiColor: .tertiaryLabel)
} }
} }
return Button { return Button {
@@ -197,10 +194,11 @@ struct AddGroupMembersViewCommon: View {
} }
} label: { } label: {
HStack{ HStack{
ProfileImage(imageStr: contact.image, size: 30) ProfileImage(imageStr: contact.image)
.frame(width: 30, height: 30)
.padding(.trailing, 2) .padding(.trailing, 2)
Text(ChatInfo.direct(contact: contact).chatViewName) Text(ChatInfo.direct(contact: contact).chatViewName)
.foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground) .foregroundColor(prohibitedToInviteIncognito ? .secondary : .primary)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer()
Image(systemName: icon) Image(systemName: icon)
@@ -210,7 +208,7 @@ struct AddGroupMembersViewCommon: View {
} }
} }
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding, _ onBackgroundColor: Color, _ secondaryColor: Color) -> some View { func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding) -> some View {
HStack { HStack {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.resizable() .resizable()
@@ -219,7 +217,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding,
.padding(.trailing, 10) .padding(.trailing, 10)
TextField("Search", text: text) TextField("Search", text: text)
.focused(focussed) .focused(focussed)
.foregroundColor(onBackgroundColor) .foregroundColor(.primary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.resizable() .resizable()
@@ -231,7 +229,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding,
focussed.wrappedValue = false focussed.wrappedValue = false
} }
} }
.foregroundColor(secondaryColor) .foregroundColor(.secondary)
.frame(height: 36) .frame(height: 36)
} }
@@ -13,16 +13,13 @@ let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
struct GroupChatInfoView: View { struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
var onSearch: () -> Void
@State private var alert: GroupChatInfoViewAlert? = nil @State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String? @State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var groupLinkNavLinkActive: Bool = false @State private var showAddMembersSheet: Bool = false
@State private var addMembersNavLinkActive: Bool = false
@State private var connectionStats: ConnectionStats? @State private var connectionStats: ConnectionStats?
@State private var connectionCode: String? @State private var connectionCode: String?
@State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceipts = SendReceipts.userDefault(true)
@@ -42,7 +39,7 @@ struct GroupChatInfoView: View {
case blockForAllAlert(mem: GroupMember) case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember) case unblockForAllAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String { var id: String {
switch self { switch self {
@@ -65,20 +62,11 @@ struct GroupChatInfoView: View {
NavigationView { NavigationView {
let members = chatModel.groupMembers let members = chatModel.groupMembers
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved } .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } .sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
List { List {
groupInfoHeader() groupInfoHeader()
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.padding(.bottom, 18)
infoActionButtons()
.padding(.horizontal)
.frame(maxWidth: .infinity)
.frame(height: infoViewActionButtonHeight)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section { Section {
if groupInfo.canEdit { if groupInfo.canEdit {
@@ -93,19 +81,13 @@ struct GroupChatInfoView: View {
} else { } else {
sendReceiptsOptionDisabled() sendReceiptsOptionDisabled()
} }
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
} header: { } header: {
Text("") Text("")
} footer: { } footer: {
Text("Only group owners can change group preferences.") Text("Only group owners can change group preferences.")
.foregroundColor(theme.colors.secondary)
} }
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { Section("\(members.count + 1) members") {
if groupInfo.canAddMembers { if groupInfo.canAddMembers {
groupLinkButton() groupLinkButton()
if (chat.chatInfo.incognito) { if (chat.chatInfo.incognito) {
@@ -117,7 +99,7 @@ struct GroupChatInfoView: View {
} }
} }
if members.count > 8 { if members.count > 8 {
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) searchFieldView(text: $searchText, focussed: $searchFocussed)
.padding(.leading, 8) .padding(.leading, 8)
} }
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
@@ -147,13 +129,12 @@ struct GroupChatInfoView: View {
} }
if developerTools { if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { Section(header: Text("For console")) {
infoRow("Local name", chat.chatInfo.localDisplayName) infoRow("Local name", chat.chatInfo.localDisplayName)
infoRow("Database ID", "\(chat.chatInfo.apiId)") infoRow("Database ID", "\(chat.chatInfo.apiId)")
} }
} }
} }
.modifier(ThemedBackground(grouped: true))
.navigationBarHidden(true) .navigationBarHidden(true)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -169,7 +150,7 @@ struct GroupChatInfoView: View {
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem) case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem) case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
} }
.onAppear { .onAppear {
@@ -185,12 +166,14 @@ struct GroupChatInfoView: View {
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
} }
} }
.keyboardPadding()
} }
private func groupInfoHeader() -> some View { private func groupInfoHeader() -> some View {
VStack { VStack {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill)) ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
.frame(width: 192, height: 192)
.padding(.top, 12) .padding(.top, 12)
.padding() .padding()
Text(cInfo.displayName) Text(cInfo.displayName)
@@ -208,116 +191,45 @@ struct GroupChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
func infoActionButtons() -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
searchButton(width: buttonWidth)
if groupInfo.canAddMembers {
addMembersActionButton(width: buttonWidth)
}
muteButton(width: buttonWidth)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func searchButton(width: CGFloat) -> some View {
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
dismiss()
onSearch()
}
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
}
@ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View {
if chat.chatInfo.incognito {
ZStack {
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
groupLinkNavLinkActive = true
}
NavigationLink(isActive: $groupLinkNavLinkActive) {
groupLinkDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
.disabled(!groupInfo.ready)
} else {
ZStack {
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
addMembersNavLinkActive = true
}
NavigationLink(isActive: $addMembersNavLinkActive) {
addMembersDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
.disabled(!groupInfo.ready)
}
}
private func muteButton(width: CGFloat) -> some View {
InfoViewButton(
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
width: width
) {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
}
.disabled(!groupInfo.ready)
}
private func addMembersButton() -> some View { private func addMembersButton() -> some View {
NavigationLink { NavigationLink {
addMembersDestinationView() AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear {
searchFocussed = false
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
}
}
}
} label: { } label: {
Label("Invite members", systemImage: "plus") Label("Invite members", systemImage: "plus")
} }
} }
private func addMembersDestinationView() -> some View {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear {
searchFocussed = false
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
chatModel.populateGroupMembersIndexes()
}
}
}
}
private struct MemberRowView: View { private struct MemberRowView: View {
var groupInfo: GroupInfo var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember @ObservedObject var groupMember: GMember
@EnvironmentObject var theme: AppTheme
var user: Bool = false var user: Bool = false
@Binding var alert: GroupChatInfoViewAlert? @Binding var alert: GroupChatInfoViewAlert?
var body: some View { var body: some View {
let member = groupMember.wrapped let member = groupMember.wrapped
let v = HStack{ let v = HStack{
MemberProfileImage(member, size: 38) ProfileImage(imageStr: member.image)
.frame(width: 38, height: 38)
.padding(.trailing, 2) .padding(.trailing, 2)
// TODO server connection status // TODO server connection status
VStack(alignment: .leading) { VStack(alignment: .leading) {
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
(member.verified ? memberVerifiedShield + t : t) (member.verified ? memberVerifiedShield + t : t)
.lineLimit(1) .lineLimit(1)
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member))) let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
.lineLimit(1) .lineLimit(1)
.font(.caption) .font(.caption)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
memberInfo(member) memberInfo(member)
@@ -347,25 +259,15 @@ struct GroupChatInfoView: View {
} }
} }
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
if member.activeConn?.connDisabled ?? false {
return "disabled"
} else if member.activeConn?.connInactive ?? false {
return "inactive"
} else {
return member.memberStatus.shortText
}
}
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View { @ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
if member.blocked { if member.blocked {
Text("blocked") Text("blocked")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} else { } else {
let role = member.memberRole let role = member.memberRole
if [.owner, .admin, .observer].contains(role) { if [.owner, .admin, .observer].contains(role) {
Text(member.memberRole.text) Text(member.memberRole.text)
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} }
} }
} }
@@ -376,13 +278,13 @@ struct GroupChatInfoView: View {
Button { Button {
alert = .blockMemberAlert(mem: member) alert = .blockMemberAlert(mem: member)
} label: { } label: {
Label("Block member", systemImage: "hand.raised").foregroundColor(theme.colors.secondary) Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
} }
} else { } else {
Button { Button {
alert = .unblockMemberAlert(mem: member) alert = .unblockMemberAlert(mem: member)
} label: { } label: {
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary) Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
} }
} }
} }
@@ -394,13 +296,13 @@ struct GroupChatInfoView: View {
Button { Button {
alert = .unblockForAllAlert(mem: member) alert = .unblockForAllAlert(mem: member)
} label: { } label: {
Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary) Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
} }
} else { } else {
Button { Button {
alert = .blockForAllAlert(mem: member) alert = .blockForAllAlert(mem: member)
} label: { } label: {
Label("Block for all", systemImage: "hand.raised").foregroundColor(theme.colors.secondary) Label("Block for all", systemImage: "hand.raised").foregroundColor(.secondary)
} }
} }
} }
@@ -416,14 +318,6 @@ struct GroupChatInfoView: View {
} }
} }
} }
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(theme.colors.secondary)
}
} }
private func memberInfoView(_ groupMember: GMember) -> some View { private func memberInfoView(_ groupMember: GMember) -> some View {
@@ -433,7 +327,15 @@ struct GroupChatInfoView: View {
private func groupLinkButton() -> some View { private func groupLinkButton() -> some View {
NavigationLink { NavigationLink {
groupLinkDestinationView() GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.navigationBarTitleDisplayMode(.large)
} label: { } label: {
if groupLink == nil { if groupLink == nil {
Label("Create group link", systemImage: "link.badge.plus") Label("Create group link", systemImage: "link.badge.plus")
@@ -443,19 +345,6 @@ struct GroupChatInfoView: View {
} }
} }
private func groupLinkDestinationView() -> some View {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
}
private func editGroupButton() -> some View { private func editGroupButton() -> some View {
NavigationLink { NavigationLink {
GroupProfileView( GroupProfileView(
@@ -463,7 +352,6 @@ struct GroupChatInfoView: View {
groupProfile: groupInfo.groupProfile groupProfile: groupInfo.groupProfile
) )
.navigationBarTitle("Group profile") .navigationBarTitle("Group profile")
.modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} label: { } label: {
Label("Edit group profile", systemImage: "pencil") Label("Edit group profile", systemImage: "pencil")
@@ -478,7 +366,6 @@ struct GroupChatInfoView: View {
welcomeText: groupInfo.groupProfile.description ?? "" welcomeText: groupInfo.groupProfile.description ?? ""
) )
.navigationTitle("Welcome message") .navigationTitle("Welcome message")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} label: { } label: {
groupInfo.groupProfile.description == nil groupInfo.groupProfile.description == nil
@@ -633,7 +520,6 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
creatingGroup: creatingGroup creatingGroup: creatingGroup
) )
.navigationBarTitle("Group preferences") .navigationBarTitle("Group preferences")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} label: { } label: {
if creatingGroup { if creatingGroup {
@@ -644,6 +530,14 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
} }
} }
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(.secondary)
}
func cantInviteIncognitoAlert() -> Alert { func cantInviteIncognitoAlert() -> Alert {
Alert( Alert(
title: Text("Can't invite contacts!"), title: Text("Can't invite contacts!"),
@@ -662,8 +556,7 @@ struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupChatInfoView( GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData), groupInfo: Binding.constant(GroupInfo.sampleData)
onSearch: {}
) )
} }
} }
@@ -22,7 +22,7 @@ struct GroupLinkView: View {
private enum GroupLinkAlert: Identifiable { private enum GroupLinkAlert: Identifiable {
case deleteLink case deleteLink
case error(title: LocalizedStringKey, error: LocalizedStringKey?) case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String { var id: String {
switch self { switch self {
@@ -34,13 +34,14 @@ struct GroupLinkView: View {
var body: some View { var body: some View {
if creatingGroup { if creatingGroup {
groupLinkView() NavigationView {
.navigationBarBackButtonHidden() groupLinkView()
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button ("Continue") { linkCreatedCb?() } Button ("Continue") { linkCreatedCb?() }
}
} }
} }
} else { } else {
groupLinkView() groupLinkView()
} }
@@ -112,7 +113,7 @@ struct GroupLinkView: View {
}, secondaryButton: .cancel() }, secondaryButton: .cancel()
) )
case let .error(title, error): case let .error(title, error):
return mkAlert(title: title, message: error) return Alert(title: Text(title), message: Text(error))
} }
} }
.onChange(of: groupLinkMemberRole) { _ in .onChange(of: groupLinkMemberRole) { _ in
@@ -132,7 +133,6 @@ struct GroupLinkView: View {
shouldCreate = false shouldCreate = false
} }
} }
.modifier(ThemedBackground(grouped: true))
} }
private func createGroupLink() { private func createGroupLink() {
@@ -11,7 +11,6 @@ import SimpleXChat
struct GroupMemberInfoView: View { struct GroupMemberInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@State var groupInfo: GroupInfo @State var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember @ObservedObject var groupMember: GMember
@@ -36,9 +35,7 @@ struct GroupMemberInfoView: View {
case abortSwitchAddressAlert case abortSwitchAddressAlert
case syncConnectionForceAlert case syncConnectionForceAlert
case planAndConnectAlert(alert: PlanAndConnectAlert) case planAndConnectAlert(alert: PlanAndConnectAlert)
case queueInfo(info: String) case error(title: LocalizedStringKey, error: LocalizedStringKey)
case someAlert(alert: SomeAlert)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String { var id: String {
switch self { switch self {
@@ -52,8 +49,6 @@ struct GroupMemberInfoView: View {
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)" case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .queueInfo(info): return "queueInfo \(info)"
case let .someAlert(alert): return "someAlert \(alert.id)"
case let .error(title, _): return "error \(title)" case let .error(title, _): return "error \(title)"
} }
} }
@@ -67,11 +62,10 @@ struct GroupMemberInfoView: View {
} }
} }
private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? { private func knownDirectChat(_ contactId: Int64) -> Chat? {
if let chat = chatModel.getContactChat(contactId), if let chat = chatModel.getContactChat(contactId),
let contact = chat.chatInfo.contact, chat.chatInfo.contact?.directOrUsed == true {
contact.directOrUsed == true { return chat
return (chat, contact)
} else { } else {
return nil return nil
} }
@@ -79,153 +73,142 @@ struct GroupMemberInfoView: View {
private func groupMemberInfoView() -> some View { private func groupMemberInfoView() -> some View {
ZStack { ZStack {
let member = groupMember.wrapped VStack {
List { let member = groupMember.wrapped
groupMemberInfoHeader(member) List {
.listRowBackground(Color.clear) groupMemberInfoHeader(member)
.listRowSeparator(.hidden) .listRowBackground(Color.clear)
.padding(.bottom, 18)
infoActionButtons(member) if member.memberActive {
.padding(.horizontal) Section {
.frame(maxWidth: .infinity) if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
.frame(height: infoViewActionButtonHeight) knownDirectChatButton(chat)
.listRowBackground(Color.clear) } else if groupInfo.fullGroupPreferences.directMessages.on {
.listRowSeparator(.hidden) if let contactId = member.memberContactId {
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
if member.memberActive { createMemberContactButton()
Section { }
if let code = connectionCode { verifyCodeButton(code) } }
if let connStats = connectionStats, if let code = connectionCode { verifyCodeButton(code) }
connStats.ratchetSyncAllowed { if let connStats = connectionStats,
synchronizeConnectionButton() connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
} }
}
if let contactLink = member.contactLink { if let contactLink = member.contactLink {
Section { Section {
SimpleXLinkQRCode(uri: contactLink) SimpleXLinkQRCode(uri: contactLink)
Button { Button {
showShareSheet(items: [simplexChatLink(contactLink)]) showShareSheet(items: [simplexChatLink(contactLink)])
} label: { } label: {
Label("Share address", systemImage: "square.and.arrow.up") Label("Share address", systemImage: "square.and.arrow.up")
} }
if let contactId = member.memberContactId { if let contactId = member.memberContactId {
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
connectViaAddressButton(contactLink)
}
} else {
connectViaAddressButton(contactLink) 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 { } else {
connectViaAddressButton(contactLink) infoRow("Role", member.memberRole.text)
} }
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
.foregroundColor(theme.colors.secondary)
}
}
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) { // TODO invited by - need to get contact by contact id
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)
}
}
if let connStats = connectionStats {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
// 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 }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
}
if groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
if let conn = member.activeConn { 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) 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) infoRow("Connection", connLevelDesc)
} }
Button ("Debug delivery") { }
Task {
do { if let connStats = connectionStats {
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId)) Section("Servers") {
await MainActor.run { alert = .queueInfo(info: info) } // TODO network connection status
} catch let e { Button("Change receiving address") {
logger.error("apiContactQueueInfo error: \(responseError(e))") alert = .switchAddressAlert
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
} }
.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 groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
}
if developerTools {
Section("For console") {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
} }
} }
} }
} .navigationBarHidden(true)
.navigationBarHidden(true) .onAppear {
.onAppear { if #unavailable(iOS 16) {
if #unavailable(iOS 16) { // this condition prevents re-setting picker
// this condition prevents re-setting picker if !justOpened { return }
if !justOpened { return } }
} justOpened = false
justOpened = false DispatchQueue.main.async {
DispatchQueue.main.async { newRole = member.memberRole
newRole = member.memberRole do {
do { let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) _ = chatModel.upsertGroupMember(groupInfo, mem)
_ = chatModel.upsertGroupMember(groupInfo, mem) connectionStats = stats
connectionStats = stats connectionCode = code
connectionCode = code } catch let error {
} catch let error { logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))") }
} }
} }
} .onChange(of: newRole) { newRole in
.onChange(of: newRole) { newRole in if newRole != member.memberRole {
if newRole != member.memberRole { alert = .changeMemberRoleAlert(mem: member, role: newRole)
alert = .changeMemberRoleAlert(mem: member, role: newRole) }
}
.onChange(of: member.memberRole) { role in
newRole = role
} }
}
.onChange(of: member.memberRole) { role in
newRole = role
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in .alert(item: $alert) { alertItem in
@@ -240,9 +223,7 @@ struct GroupMemberInfoView: View {
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true) case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
case let .queueInfo(info): return queueInfoAlert(info) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error)
} }
} }
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
@@ -251,58 +232,6 @@ struct GroupMemberInfoView: View {
ProgressView().scaleEffect(2) ProgressView().scaleEffect(2)
} }
} }
.modifier(ThemedBackground(grouped: true))
}
func infoActionButtons(_ member: GroupMember) -> some View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
knownDirectChatButton(chat, width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton(width: buttonWidth)
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
}
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
}
} else { // no known contact chat && directMessages are off
InfoViewButton(image: "message.fill", title: "message", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't message member")
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
}
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
func showSendMessageToEnableCallsAlert() {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't call member",
message: "Send message to enable calls."
),
id: "can't call member, send message"
))
}
func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: title,
message: "Direct messages between members are prohibited in this group."
),
id: "can't message member, direct messages prohibited"
))
} }
func connectViaAddressButton(_ contactLink: String) -> some View { func connectViaAddressButton(_ contactLink: String) -> some View {
@@ -319,32 +248,36 @@ struct GroupMemberInfoView: View {
} }
} }
func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View { func knownDirectChatButton(_ chat: Chat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { Button {
ItemsModel.shared.loadOpenChat(chat.id) { dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
func newDirectChatButton(_ contactId: Int64) -> some View {
Button {
do {
let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
} DispatchQueue.main.async {
} chatModel.chatId = chat.id
}
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
do {
let chat = try await apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true)
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
} }
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
} }
} label: {
Label("Send direct message", systemImage: "message")
} }
} }
func createMemberContactButton(width: CGFloat) -> some View { func createMemberContactButton() -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { Button {
progressIndicator = true progressIndicator = true
Task { Task {
do { do {
@@ -352,10 +285,9 @@ struct GroupMemberInfoView: View {
await MainActor.run { await MainActor.run {
progressIndicator = false progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
ItemsModel.shared.loadOpenChat(memberContact.id) { dismissAllSheets(animated: true)
dismissAllSheets(animated: true) chatModel.chatId = memberContact.id
} chatModel.setContactNetworkStatus(memberContact, .connected)
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
} }
} catch let error { } catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))") logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
@@ -366,18 +298,21 @@ struct GroupMemberInfoView: View {
} }
} }
} }
} label: {
Label("Send direct message", systemImage: "message")
} }
} }
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View { private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
VStack { VStack {
MemberProfileImage(mem, size: 192, color: Color(uiColor: .tertiarySystemFill)) ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
.frame(width: 192, height: 192)
.padding(.top, 12) .padding(.top, 12)
.padding() .padding()
if mem.verified { if mem.verified {
( (
Text(Image(systemName: "checkmark.shield")) Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.font(.title2) .font(.title2)
+ Text(" ") + Text(" ")
+ Text(mem.displayName) + Text(mem.displayName)
@@ -425,7 +360,6 @@ struct GroupMemberInfoView: View {
) )
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code") .navigationTitle("Security code")
.modifier(ThemedBackground())
} label: { } label: {
Label( Label(
member.verified ? "View security code" : "Verify security code", member.verified ? "View security code" : "Verify security code",
@@ -475,7 +409,7 @@ struct GroupMemberInfoView: View {
Section { Section {
if mem.blockedByAdmin { if mem.blockedByAdmin {
Label("Blocked by admin", systemImage: "hand.raised") Label("Blocked by admin", systemImage: "hand.raised")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
} else if mem.memberSettings.showMessages { } else if mem.memberSettings.showMessages {
blockMemberButton(mem) blockMemberButton(mem)
} else { } else {
@@ -633,21 +567,6 @@ struct GroupMemberInfoView: View {
} }
} }
func MemberProfileImage(
_ mem: GroupMember,
size: CGFloat,
color: Color = Color(uiColor: .tertiarySystemGroupedBackground),
backgroundColor: Color? = nil
) -> some View {
ProfileImage(
imageStr: mem.image,
size: size,
color: color,
backgroundColor: backgroundColor,
blurred: mem.blocked
)
}
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert( Alert(
title: Text("Block member?"), title: Text("Block member?"),
@@ -9,16 +9,9 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
private let featureRoles: [(role: GroupMemberRole?, text: LocalizedStringKey)] = [
(nil, "all members"),
(.admin, "admins"),
(.owner, "owners")
]
struct GroupPreferencesView: View { struct GroupPreferencesView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
@State var preferences: FullGroupPreferences @State var preferences: FullGroupPreferences
@State var currentPreferences: FullGroupPreferences @State var currentPreferences: FullGroupPreferences
@@ -31,11 +24,10 @@ struct GroupPreferencesView: View {
List { List {
featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.timedMessages, $preferences.timedMessages.enable)
featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.fullDelete, $preferences.fullDelete.enable)
featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role) featureSection(.directMessages, $preferences.directMessages.enable)
featureSection(.reactions, $preferences.reactions.enable) featureSection(.reactions, $preferences.reactions.enable)
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role) featureSection(.voice, $preferences.voice.enable)
featureSection(.files, $preferences.files.enable, $preferences.files.role) featureSection(.files, $preferences.files.enable)
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
featureSection(.history, $preferences.history.enable) featureSection(.history, $preferences.history.enable)
if groupInfo.canEdit { if groupInfo.canEdit {
@@ -72,9 +64,9 @@ struct GroupPreferencesView: View {
} }
} }
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View { private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
Section { Section {
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
if groupInfo.canEdit { if groupInfo.canEdit {
@@ -95,14 +87,6 @@ struct GroupPreferencesView: View {
) )
.frame(height: 36) .frame(height: 36)
} }
if enableFeature.wrappedValue == .on, let enableForRole {
Picker("Enabled for", selection: enableForRole) {
ForEach(featureRoles, id: \.role) { fr in
Text(fr.text)
}
}
.frame(height: 36)
}
} else { } else {
settingsRow(icon, color: color) { settingsRow(icon, color: color) {
infoRow(Text(feature.text), enableFeature.wrappedValue.text) infoRow(Text(feature.text), enableFeature.wrappedValue.text)
@@ -110,26 +94,9 @@ struct GroupPreferencesView: View {
if timedOn { if timedOn {
infoRow("Delete after", timeText(preferences.timedMessages.ttl)) infoRow("Delete after", timeText(preferences.timedMessages.ttl))
} }
if enableFeature.wrappedValue == .on, let enableForRole {
HStack {
Text("Enabled for").foregroundColor(theme.colors.secondary)
Spacer()
Text(
featureRoles.first(where: { fr in fr.role == enableForRole.wrappedValue })?.text
?? "all members"
)
.foregroundColor(theme.colors.secondary)
}
}
} }
} footer: { } footer: {
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit)) Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
.foregroundColor(theme.colors.secondary)
}
.onChange(of: enableFeature.wrappedValue) { enabled in
if case .off = enabled {
enableForRole?.wrappedValue = nil
}
} }
} }
@@ -11,7 +11,6 @@ import SimpleXChat
struct GroupWelcomeView: View { struct GroupWelcomeView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
@State var groupProfile: GroupProfile @State var groupProfile: GroupProfile
@State var welcomeText: String @State var welcomeText: String
@@ -58,7 +57,7 @@ struct GroupWelcomeView: View {
} }
private func textPreview() -> some View { private func textPreview() -> some View {
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary) messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false)
.frame(minHeight: 130, alignment: .topLeading) .frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@@ -71,7 +70,7 @@ struct GroupWelcomeView: View {
Group { Group {
if welcomeText.isEmpty { if welcomeText.isEmpty {
TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder"))) TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder")))
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
.disabled(true) .disabled(true)
} }
TextEditor(text: $welcomeText) TextEditor(text: $welcomeText)
@@ -1,309 +0,0 @@
//
// ReverseList.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 11/06/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import Combine
/// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
let items: Array<Item>
@Binding var scrollState: ReverseListScrollModel<Item>.State
/// Closure, that returns user interface for a given item
let content: (Item) -> Content
let loadPage: () -> Void
func makeUIViewController(context: Context) -> Controller {
Controller(representer: self)
}
func updateUIViewController(_ controller: Controller, context: Context) {
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
switch destination {
case .nextPage:
controller.scrollToNextPage()
case let .item(id):
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
case .bottom:
controller.scroll(to: 0, position: .top)
}
} else {
controller.update(items: items)
}
}
/// Controller, which hosts SwiftUI cells
class Controller: UITableViewController {
private enum Section { case main }
private let representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var itemCount: Int = 0
private var bag = Set<AnyCancellable>()
init(representer: ReverseList) {
self.representer = representer
super.init(style: .plain)
// 1. Style
tableView = InvertedTableView()
tableView.separatorStyle = .none
tableView.transform = .verticalFlip
tableView.backgroundColor = .clear
// 2. Register cells
if #available(iOS 16.0, *) {
tableView.register(
UITableViewCell.self,
forCellReuseIdentifier: cellReuseId
)
} else {
tableView.register(
HostingCell<Content>.self,
forCellReuseIdentifier: cellReuseId
)
}
// 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, Item>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
self.representer.loadPage()
}
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
if #available(iOS 16.0, *) {
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
.margins(.all, 0)
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
} else {
if let cell = cell as? HostingCell<Content> {
cell.set(content: self.representer.content(item), parent: self)
} else {
fatalError("Unexpected Cell Type for: \(item)")
}
}
cell.transform = .verticalFlip
cell.selectionStyle = .none
cell.backgroundColor = .clear
return cell
}
// 4. External state changes will require manual layout updates
NotificationCenter.default
.addObserver(
self,
selector: #selector(updateLayout),
name: notificationName,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit { NotificationCenter.default.removeObserver(self) }
@objc private func updateLayout() {
if #available(iOS 16.0, *) {
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
} else {
tableView.reloadData()
}
}
/// Hides keyboard, when user begins to scroll.
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
UIApplication.shared
.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
}
override func viewDidAppear(_ animated: Bool) {
tableView.clipsToBounds = false
parent?.viewIfLoaded?.clipsToBounds = false
}
/// Scrolls up
func scrollToNextPage() {
tableView.setContentOffset(
CGPoint(
x: tableView.contentOffset.x,
y: tableView.contentOffset.y + tableView.bounds.height
),
animated: true
)
Task { representer.scrollState = .atDestination }
}
/// Scrolls to Item at index path
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
var animated = false
if #available(iOS 16.0, *) {
animated = true
}
if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow(
at: IndexPath(row: index, section: 0),
at: position,
animated: animated
)
} else {
tableView.setContentOffset(
CGPoint(x: .zero, y: -InvertedTableView.inset),
animated: animated
)
}
Task { representer.scrollState = .atDestination }
}
func update(items: Array<Item>) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none
dataSource.apply(
snapshot,
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
)
itemCount = items.count
}
}
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
private final class HostingCell<Hosted: View>: UITableViewCell {
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
/// Updates content of the cell
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
func set(content: Hosted, parent: UIViewController) {
hostingController.view.backgroundColor = .clear
hostingController.rootView = content
if let hostingView = hostingController.view {
hostingView.invalidateIntrinsicContentSize()
if hostingController.parent != parent { parent.addChild(hostingController) }
if !contentView.subviews.contains(hostingController.view) {
contentView.addSubview(hostingController.view)
hostingView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingView.leadingAnchor
.constraint(equalTo: contentView.leadingAnchor),
hostingView.trailingAnchor
.constraint(equalTo: contentView.trailingAnchor),
hostingView.topAnchor
.constraint(equalTo: contentView.topAnchor),
hostingView.bottomAnchor
.constraint(equalTo: contentView.bottomAnchor)
])
}
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
} else {
fatalError("Hosting View not loaded \(hostingController)")
}
}
override func prepareForReuse() {
super.prepareForReuse()
hostingController.rootView = nil
}
}
}
/// Manages ``ReverseList`` scrolling
class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
/// Represents Scroll State of ``ReverseList``
enum State: Equatable {
enum Destination: Equatable {
case nextPage
case item(Item.ID)
case bottom
}
case scrollingTo(Destination)
case atDestination
}
@Published var state: State = .atDestination
func scrollToNextPage() {
state = .scrollingTo(.nextPage)
}
func scrollToBottom() {
state = .scrollingTo(.bottom)
}
func scrollToItem(id: Item.ID) {
state = .scrollingTo(.item(id))
}
}
fileprivate let cellReuseId = "hostingCell"
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
fileprivate extension CGAffineTransform {
/// Transform that vertically flips the view, preserving it's location
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
}
extension NotificationCenter {
static func postReverseListNeedsLayout() {
NotificationCenter.default.post(
name: notificationName,
object: nil
)
}
}
/// Disable animation on iOS 15
func withConditionalAnimation<Result>(
_ animation: Animation? = .default,
_ body: () throws -> Result
) rethrows -> Result {
if #available(iOS 16.0, *) {
try withAnimation(animation, body)
} else {
try body()
}
}
class InvertedTableView: UITableView {
static let inset = CGFloat(100)
static let insets = UIEdgeInsets(
top: inset,
left: .zero,
bottom: inset,
right: .zero
)
override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
get { .never }
set { }
}
override var contentInset: UIEdgeInsets {
get { Self.insets }
set { }
}
override var adjustedContentInset: UIEdgeInsets {
Self.insets
}
}
@@ -1,133 +0,0 @@
//
// SelectableChatItemToolbars.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 30.07.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SelectedItemsTopToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@Binding var selectedChatItems: Set<Int64>?
var body: some View {
let count = selectedChatItems?.count ?? 0
return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline)
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
}
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let chatItems: [ChatItem]
@Binding var selectedChatItems: Set<Int64>?
var chatInfo: ChatInfo
// Bool - delete for everyone is possible
var deleteItems: (Bool) -> Void
var moderateItems: () -> Void
//var shareItems: () -> Void
@State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false
@State var canModerate: Bool = false
@State var moderateEnabled: Bool = false
@State var allButtonsDisabled = false
var body: some View {
VStack(spacing: 0) {
Divider()
HStack(alignment: .center) {
Button {
deleteItems(deleteForEveryoneEnabled)
} label: {
Image(systemName: "trash")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
}
.disabled(!deleteEnabled || allButtonsDisabled)
Spacer()
Button {
moderateItems()
} label: {
Image(systemName: "flag")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
}
.disabled(!moderateEnabled || allButtonsDisabled)
.opacity(canModerate ? 1 : 0)
Spacer()
Button {
//shareItems()
} label: {
Image(systemName: "square.and.arrow.up")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
}
.disabled(allButtonsDisabled)
.opacity(0)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
recheckItems(chatInfo, chatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
recheckItems(info, chatItems, selectedChatItems)
}
.onChange(of: chatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
recheckItems(chatInfo, chatItems, selected)
}
.frame(height: 55.5)
.background(.thinMaterial)
}
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
let count = selectedItems?.count ?? 0
allButtonsDisabled = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
if let selected = selectedItems {
let me: Bool
let onlyOwnGroupItems: Bool
(deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) {
var (de, dee, me, onlyOwnGroupItems, sel) = r
de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
return (de, dee, me, onlyOwnGroupItems, sel)
} else {
return r
}
}
moderateEnabled = me && !onlyOwnGroupItems
}
}
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
case let .group(groupInfo):
groupInfo.membership.memberRole >= .admin
default: false
}
}
}
@@ -10,7 +10,6 @@ import SwiftUI
struct VerifyCodeView: View { struct VerifyCodeView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
var displayName: String var displayName: String
@State var connectionCode: String? @State var connectionCode: String?
@State var connectionVerified: Bool @State var connectionVerified: Bool
@@ -31,7 +30,7 @@ struct VerifyCodeView: View {
HStack { HStack {
if connectionVerified { if connectionVerified {
Image(systemName: "checkmark.shield") Image(systemName: "checkmark.shield")
.foregroundColor(theme.colors.secondary) .foregroundColor(.secondary)
Text("\(displayName) is verified") Text("\(displayName) is verified")
} else { } else {
Text("\(displayName) is not verified") Text("\(displayName) is not verified")
@@ -67,7 +66,6 @@ struct VerifyCodeView: View {
ScanCodeView(connectionVerified: $connectionVerified, verify: verify) ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.navigationTitle("Scan code") .navigationTitle("Scan code")
.modifier(ThemedBackground())
} label: { } label: {
Label("Scan code", systemImage: "qrcode") Label("Scan code", systemImage: "qrcode")
} }
@@ -124,6 +122,5 @@ struct VerifyCodeView: View {
struct VerifyCodeView_Previews: PreviewProvider { struct VerifyCodeView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil}) VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil})
.environmentObject(CurrentColors.toAppTheme())
} }
} }
@@ -11,6 +11,7 @@ import SwiftUI
struct ChatHelp: View { struct ChatHelp: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool @Binding var showSettings: Bool
@State private var newChatMenuOption: NewChatMenuOption? = nil
var body: some View { var body: some View {
ScrollView { chatHelp() } ScrollView { chatHelp() }
@@ -38,7 +39,7 @@ struct ChatHelp: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Text("Tap button ") Text("Tap button ")
NewChatMenuButton() NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
Text("above, then choose:") Text("above, then choose:")
} }
@@ -9,56 +9,34 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
typealias DynamicSizes = ( private let rowHeights: [DynamicTypeSize: CGFloat] = [
rowHeight: CGFloat, .xSmall: 68,
profileImageSize: CGFloat, .small: 72,
mediaSize: CGFloat, .medium: 76,
incognitoSize: CGFloat, .large: 80,
chatInfoSize: CGFloat, .xLarge: 88,
unreadCorner: CGFloat, .xxLarge: 94,
unreadPadding: CGFloat .xxxLarge: 104,
) .accessibility1: 90,
.accessibility2: 100,
private let dynamicSizes: [DynamicTypeSize: DynamicSizes] = [ .accessibility3: 120,
.xSmall: (68, 55, 33, 22, 18, 9, 3), .accessibility4: 130,
.small: (72, 57, 34, 22, 18, 9, 3), .accessibility5: 140
.medium: (76, 60, 36, 22, 18, 10, 4),
.large: (80, 63, 38, 24, 20, 10, 4),
.xLarge: (88, 67, 41, 24, 20, 10, 4),
.xxLarge: (100, 71, 44, 27, 22, 11, 4),
.xxxLarge: (110, 75, 48, 30, 24, 12, 5),
.accessibility1: (110, 75, 48, 30, 24, 12, 5),
.accessibility2: (114, 75, 48, 30, 24, 12, 5),
.accessibility3: (124, 75, 48, 30, 24, 12, 5),
.accessibility4: (134, 75, 48, 30, 24, 12, 5),
.accessibility5: (144, 75, 48, 30, 24, 12, 5)
] ]
private let defaultDynamicSizes: DynamicSizes = dynamicSizes[.large]!
func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
dynamicSizes[font] ?? defaultDynamicSizes
}
struct ChatListNavLink: View { struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@State private var showContactRequestDialog = false @State private var showContactRequestDialog = false
@State private var showJoinGroupDialog = false @State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false @State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false @State private var showInvalidJSON = false
@State private var alert: SomeAlert? = nil @State private var showDeleteContactActionSheet = false
@State private var actionSheet: SomeActionSheet? = nil
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var showConnectContactViaAddressDialog = false @State private var showConnectContactViaAddressDialog = false
@State private var inProgress = false @State private var inProgress = false
@State private var progressByTimeout = false @State private var progressByTimeout = false
var dynamicRowHeight: CGFloat { dynamicSize(userFont).rowHeight }
var body: some View { var body: some View {
Group { Group {
switch chat.chatInfo { switch chat.chatInfo {
@@ -89,21 +67,14 @@ struct ChatListNavLink: View {
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
Group { Group {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { if contact.activeConn == nil && contact.profile.contactLink != nil {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
deleteContactDialog( showDeleteContactActionSheet = true
chat,
contact,
dismissToChatList: false,
showAlert: { alert = $0 },
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
@@ -114,44 +85,51 @@ struct ChatListNavLink: View {
} }
} else { } else {
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
) )
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
toggleFavoriteButton() toggleFavoriteButton()
toggleNtfsButton(chat: chat) toggleNtfsButton(chat)
} }
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty { if !chat.chatItems.isEmpty {
clearChatButton() clearChatButton()
} }
Button { Button {
deleteContactDialog( if contact.ready || !contact.active {
chat, showDeleteContactActionSheet = true
contact, } else {
dismissToChatList: false, AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
showAlert: { alert = $0 }, }
showActionSheet: { actionSheet = $0 },
showSheetContent: { sheet = $0 }
)
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
} }
} }
.alert(item: $alert) { $0.alert } .actionSheet(isPresented: $showDeleteContactActionSheet) {
.actionSheet(item: $actionSheet) { $0.actionSheet } if contact.ready && contact.active {
.sheet(item: $sheet) { return ActionSheet(
if #available(iOS 16.0, *) { title: Text("Delete contact?\nThis cannot be undone!"),
$0.content buttons: [
.presentationDetents([.fraction(0.4)]) .destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
.cancel()
]
)
} else { } else {
$0.content return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
.cancel()
]
)
} }
} }
} }
@@ -160,7 +138,7 @@ struct ChatListNavLink: View {
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memInvited: case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton() joinGroupButton()
if groupInfo.canDelete { if groupInfo.canDelete {
@@ -180,7 +158,7 @@ struct ChatListNavLink: View {
.disabled(inProgress) .disabled(inProgress)
case .memAccepted: case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { .onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
} }
@@ -194,16 +172,16 @@ struct ChatListNavLink: View {
} }
default: default:
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready disabled: !groupInfo.ready
) )
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
toggleFavoriteButton() toggleFavoriteButton()
toggleNtfsButton(chat: chat) toggleNtfsButton(chat)
} }
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty { if !chat.chatItems.isEmpty {
@@ -221,12 +199,12 @@ struct ChatListNavLink: View {
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { @ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready disabled: !noteFolder.ready
) )
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
} }
@@ -244,9 +222,9 @@ struct ChatListNavLink: View {
await MainActor.run { inProgress = false } await MainActor.run { inProgress = false }
} }
} label: { } label: {
SwipeLabel(NSLocalizedString("Join", comment: "swipe action"), systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward", inverted: oneHandUI) Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
} }
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary) .tint(chat.chatInfo.incognito ? .indigo : .accentColor)
} }
@ViewBuilder private func markReadButton() -> some View { @ViewBuilder private func markReadButton() -> some View {
@@ -254,16 +232,16 @@ struct ChatListNavLink: View {
Button { Button {
Task { await markChatRead(chat) } Task { await markChatRead(chat) }
} label: { } label: {
SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) Label("Read", systemImage: "checkmark")
} }
.tint(theme.colors.primary) .tint(Color.accentColor)
} else { } else {
Button { Button {
Task { await markChatUnread(chat) } Task { await markChatUnread(chat) }
} label: { } label: {
SwipeLabel(NSLocalizedString("Unread", comment: "swipe action"), systemImage: "circlebadge.fill", inverted: oneHandUI) Label("Unread", systemImage: "circlebadge.fill")
} }
.tint(theme.colors.primary) .tint(Color.accentColor)
} }
} }
@@ -273,36 +251,24 @@ struct ChatListNavLink: View {
Button { Button {
toggleChatFavorite(chat, favorite: false) toggleChatFavorite(chat, favorite: false)
} label: { } label: {
SwipeLabel(NSLocalizedString("Unfav.", comment: "swipe action"), systemImage: "star.slash.fill", inverted: oneHandUI) Label("Unfav.", systemImage: "star.slash")
} }
.tint(.green) .tint(.green)
} else { } else {
Button { Button {
toggleChatFavorite(chat, favorite: true) toggleChatFavorite(chat, favorite: true)
} label: { } label: {
SwipeLabel(NSLocalizedString("Favorite", comment: "swipe action"), systemImage: "star.fill", inverted: oneHandUI) Label("Favorite", systemImage: "star.fill")
} }
.tint(.green) .tint(.green)
} }
} }
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
Button {
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
} label: {
if chat.chatInfo.ntfsEnabled {
SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
} else {
SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
}
}
}
private func clearChatButton() -> some View { private func clearChatButton() -> some View {
Button { Button {
AlertManager.shared.showAlert(clearChatAlert()) AlertManager.shared.showAlert(clearChatAlert())
} label: { } label: {
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI) Label("Clear", systemImage: "gobackward")
} }
.tint(Color.orange) .tint(Color.orange)
} }
@@ -311,7 +277,7 @@ struct ChatListNavLink: View {
Button { Button {
AlertManager.shared.showAlert(clearNoteFolderAlert()) AlertManager.shared.showAlert(clearNoteFolderAlert())
} label: { } label: {
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI) Label("Clear", systemImage: "gobackward")
} }
.tint(Color.orange) .tint(Color.orange)
} }
@@ -320,7 +286,7 @@ struct ChatListNavLink: View {
Button { Button {
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo)) AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
} label: { } label: {
SwipeLabel(NSLocalizedString("Leave", comment: "swipe action"), systemImage: "rectangle.portrait.and.arrow.right.fill", inverted: oneHandUI) Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
} }
.tint(Color.yellow) .tint(Color.yellow)
} }
@@ -329,7 +295,7 @@ struct ChatListNavLink: View {
Button { Button {
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo)) AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
@@ -339,23 +305,22 @@ struct ChatListNavLink: View {
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
} label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } } label: { Label("Accept", systemImage: "checkmark") }
.tint(theme.colors.primary) .tint(.accentColor)
Button { Button {
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
} label: { } label: {
SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) Label("Accept incognito", systemImage: "theatermasks")
} }
.tint(.indigo) .tint(.indigo)
Button { Button {
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest)) AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
} label: { } label: {
SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply.fill", inverted: oneHandUI) Label("Reject", systemImage: "multiply")
} }
.tint(.red) .tint(.red)
} }
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.contentShape(Rectangle())
.onTapGesture { showContactRequestDialog = true } .onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } } Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
@@ -372,37 +337,29 @@ struct ChatListNavLink: View {
AlertManager.shared.showAlertMsg(title: a.title, message: a.message) AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
}) })
} label: { } label: {
deleteLabel Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
Button { Button {
showContactConnectionInfo = true showContactConnectionInfo = true
} label: { } label: {
SwipeLabel(NSLocalizedString("Name", comment: "swipe action"), systemImage: "pencil", inverted: oneHandUI) Label("Name", systemImage: "pencil")
} }
.tint(theme.colors.primary) .tint(.accentColor)
} }
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.appSheet(isPresented: $showContactConnectionInfo) { .sheet(isPresented: $showContactConnectionInfo) {
Group { if case let .contactConnection(contactConnection) = chat.chatInfo {
if case let .contactConnection(contactConnection) = chat.chatInfo { ContactConnectionInfo(contactConnection: contactConnection)
ContactConnectionInfo(contactConnection: contactConnection) .environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
.modifier(ThemedBackground(grouped: true))
}
} }
} }
.contentShape(Rectangle())
.onTapGesture { .onTapGesture {
showContactConnectionInfo = true showContactConnectionInfo = true
} }
} }
private var deleteLabel: some View {
SwipeLabel(NSLocalizedString("Delete", comment: "swipe action"), systemImage: "trash.fill", inverted: oneHandUI)
}
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
Alert( Alert(
title: Text("Delete group?"), title: Text("Delete group?"),
@@ -451,6 +408,28 @@ struct ChatListNavLink: View {
) )
} }
private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
Task { await rejectContactRequest(contactRequest) }
},
secondaryButton: .cancel()
)
}
private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Contact is not connected yet!"),
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
primaryButton: .cancel(),
secondaryButton: .destructive(Text("Delete Contact")) {
removePendingContact(chat, contact)
}
)
}
private func groupInvitationAcceptedAlert() -> Alert { private func groupInvitationAcceptedAlert() -> Alert {
Alert( Alert(
title: Text("Joining group"), title: Text("Joining group"),
@@ -458,13 +437,37 @@ struct ChatListNavLink: View {
) )
} }
private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Delete pending connection"),
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
primaryButton: .destructive(Text("Delete")) {
removePendingContact(chat, contact)
},
secondaryButton: .cancel()
)
}
private func removePendingContact(_ chat: Chat, _ contact: Contact) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
DispatchQueue.main.async {
chatModel.removeChat(contact.id)
}
} catch let error {
logger.error("ChatListNavLink.removePendingContact apiDeleteChat error: \(responseError(error))")
}
}
}
private func invalidJSONPreview(_ json: String) -> some View { private func invalidJSONPreview(_ json: String) -> some View {
Text("invalid chat data") Text("invalid chat data")
.foregroundColor(.red) .foregroundColor(.red)
.padding(4) .padding(4)
.frame(height: dynamicRowHeight) .frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showInvalidJSON = true } .onTapGesture { showInvalidJSON = true }
.appSheet(isPresented: $showInvalidJSON) { .sheet(isPresented: $showInvalidJSON) {
invalidJSONView(json) invalidJSONView(json)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) .environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
} }
@@ -472,26 +475,16 @@ struct ChatListNavLink: View {
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) { private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
Task { Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) let ok = await connectContactViaAddress(contact.contactId, incognito)
if ok { if ok {
ItemsModel.shared.loadOpenChat(contact.id) await MainActor.run {
AlertManager.shared.showAlert(connReqSentAlert(.contact)) chatModel.chatId = contact.id
}
} }
} }
} }
} }
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
Task { await rejectContactRequest(contactRequest) }
},
secondaryButton: .cancel()
)
}
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert { func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
Alert( Alert(
title: Text("Delete pending connection?"), title: Text("Delete pending connection?"),
@@ -518,14 +511,15 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
) )
} }
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool { func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId) let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
if let alert = alert { if let alert = alert {
showAlert(alert) AlertManager.shared.showAlert(alert)
return false return false
} else if let contact = contact { } else if let contact = contact {
await MainActor.run { await MainActor.run {
ChatModel.shared.updateContact(contact) ChatModel.shared.updateContact(contact)
AlertManager.shared.showAlert(connReqSentAlert(.contact))
} }
return true return true
} }
@@ -566,11 +560,18 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
} }
} }
struct ErrorAlert {
var title: LocalizedStringKey
var message: LocalizedStringKey
}
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert { func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
if let r = error as? ChatResponse, switch error as? ChatResponse {
let alert = getNetworkErrorAlert(r) { case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
return alert return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
} else { case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
default:
return ErrorAlert(title: title, message: "Error: \(responseError(error))") return ErrorAlert(title: title, message: "Error: \(responseError(error))")
} }
} }

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