diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d2c53bbe35..4cc2ad7dc4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -8,6 +8,7 @@ on:
- users
tags:
- "v*"
+ - "!*-fdroid"
pull_request:
jobs:
@@ -52,15 +53,19 @@ jobs:
- os: ubuntu-20.04
cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-20_04-x86-64
+ desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
- os: ubuntu-22.04
cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-22_04-x86-64
+ desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
- os: macos-latest
cache_path: ~/.cabal/store
asset_name: simplex-chat-macos-x86-64
+ desktop_asset_name: simplex-desktop-macos-x86_64.dmg
- os: windows-latest
cache_path: C:/cabal
asset_name: simplex-chat-windows-x86-64
+ desktop_asset_name: simplex-desktop-windows-x86_64.msi
steps:
- name: Configure pagefile (Windows)
if: matrix.os == 'windows-latest'
@@ -99,6 +104,10 @@ jobs:
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
+ - name: Install AppImage dependencies
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
+ run: sudo apt install -y desktop-file-utils
+
- name: Install pkg-config for Mac
if: matrix.os == 'macos-latest'
run: brew install pkg-config
@@ -111,23 +120,136 @@ jobs:
echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- - name: Unix build
- id: unix_build
+ - name: Unix build CLI
+ id: unix_cli_build
if: matrix.os != 'windows-latest'
shell: bash
run: |
cabal build --enable-tests
- echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
+ path=$(cabal list-bin simplex-chat)
+ echo "bin_path=$path" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- - name: Unix upload binary to release
+ - name: Unix upload CLI binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
- file: ${{ steps.unix_build.outputs.bin_path }}
+ file: ${{ steps.unix_cli_build.outputs.bin_path }}
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
+ - name: Unix update CLI binary hash
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
+ uses: softprops/action-gh-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ append_body: true
+ body: |
+ ${{ steps.unix_cli_build.outputs.bin_hash }}
+
+ - name: Setup Java
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'corretto'
+ java-version: '17'
+ cache: 'gradle'
+
+ - name: Linux build desktop
+ id: linux_desktop_build
+ if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
+ shell: bash
+ run: |
+ scripts/desktop/build-lib-linux.sh
+ cd apps/multiplatform
+ ./gradlew packageDeb
+ path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
+ echo "package_path=$path" >> $GITHUB_OUTPUT
+ echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Linux make AppImage
+ id: linux_appimage_build
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
+ shell: bash
+ run: |
+ scripts/desktop/make-appimage-linux.sh
+ path=$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage)
+ echo "appimage_path=$path" >> $GITHUB_OUTPUT
+ echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Mac build desktop
+ id: mac_desktop_build
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
+ shell: bash
+ env:
+ APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
+ APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
+ APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
+ run: |
+ scripts/build-desktop-mac.sh
+ path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
+ echo "package_path=$path" >> $GITHUB_OUTPUT
+ echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
+
+ - name: Linux upload desktop package to release
+ if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
+ uses: svenstaro/upload-release-action@v2
+ with:
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ file: ${{ steps.linux_desktop_build.outputs.package_path }}
+ asset_name: ${{ matrix.desktop_asset_name }}
+ tag: ${{ github.ref }}
+
+ - name: Linux update desktop package hash
+ if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
+ uses: softprops/action-gh-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ append_body: true
+ body: |
+ ${{ steps.linux_desktop_build.outputs.package_hash }}
+
+ - name: Linux upload AppImage to release
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
+ uses: svenstaro/upload-release-action@v2
+ with:
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ file: ${{ steps.linux_appimage_build.outputs.appimage_path }}
+ asset_name: simplex-desktop-x86_64.AppImage
+ tag: ${{ github.ref }}
+
+ - name: Linux update AppImage hash
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
+ uses: softprops/action-gh-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ append_body: true
+ body: |
+ ${{ steps.linux_appimage_build.outputs.appimage_hash }}
+
+ - name: Mac upload desktop package to release
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
+ uses: svenstaro/upload-release-action@v2
+ with:
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ file: ${{ steps.mac_desktop_build.outputs.package_path }}
+ asset_name: ${{ matrix.desktop_asset_name }}
+ tag: ${{ github.ref }}
+
+ - name: Mac update desktop package hash
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
+ uses: softprops/action-gh-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ append_body: true
+ body: |
+ ${{ steps.mac_desktop_build.outputs.package_hash }}
+
- name: Unix test
if: matrix.os != 'windows-latest'
timeout-minutes: 30
@@ -147,11 +269,12 @@ jobs:
shell: cmd
run: |
cabal build --enable-tests
- cabal list-bin simplex-chat > tmp_bin_path
- set /p bin_path= < tmp_bin_path
- echo ::set-output name=bin_path::%bin_path%
+ rm -rf dist-newstyle/src/direct-sq*
+ path=$(cabal list-bin simplex-chat | tail -n 1)
+ echo "bin_path=$path" >> $GITHUB_OUTPUT
+ echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- - name: Windows upload binary to release
+ - name: Windows upload CLI binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
@@ -160,4 +283,14 @@ jobs:
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
+ - name: Windows update CLI binary hash
+ if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
+ uses: softprops/action-gh-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ append_body: true
+ body: |
+ ${{ steps.windows_build.outputs.bin_hash }}
+
# Windows /
diff --git a/.gitignore b/.gitignore
index 44fd61e359..d7106ec8fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,6 +53,7 @@ website/src/docs/
website/translations.json
website/src/img/images/
website/src/images/
+website/src/js/lottie.min.js
# Generated files
website/package/generated*
diff --git a/README.md b/README.md
index d4103fd104..66465926dd 100644
--- a/README.md
+++ b/README.md
@@ -119,19 +119,22 @@ Join our translators to help SimpleX grow!
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|🇬🇧 en|English | |✓|✓|✓|✓|
-|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
-|🇧🇬 bg|Български |-|[](https://hosted.weblate.org/projects/simplex-chat/android/bg/)
-|||
+|ar|العربية |[jermanuts](https://github.com/jermanuts)|[](https://hosted.weblate.org/projects/simplex-chat/android/ar/)
-|[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
+|🇧🇬 bg|Български | |[](https://hosted.weblate.org/projects/simplex-chat/android/bg/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/bg/)|||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
+|🇫🇮 fi|Suomi | |[](https://hosted.weblate.org/projects/simplex-chat/android/fi/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
+|🇮🇱 he|עִברִית | |[](https://hosted.weblate.org/projects/simplex-chat/android/he/)
-|||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
-|🇯🇵 ja|Japanese ||[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|||
+|🇯🇵 ja|日本語 | |[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/pl/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|🇧🇷 pt-BR|Português||[](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)
-|[](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[](https://hosted.weblate.org/projects/simplex-chat/android/th/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
+|🇺🇦 uk|Українська| |[](https://hosted.weblate.org/projects/simplex-chat/android/uk/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)
[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)
[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)
|
[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
@@ -227,24 +230,18 @@ You can use SimpleX with your own servers and still communicate with people usin
## News and updates
-Recent updates:
+Recent and important updates:
-[July 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
+[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
+
+[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
-[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
-
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
-[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
-
-[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
-
-[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
-
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
@@ -290,18 +287,20 @@ What is already implemented:
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
-6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
+6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
12. Manual messaging queue rotations to move conversation to another SMP relay.
+13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
+14. Local files encryption, except videos (to be added later).
We plan to add:
-1. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`. This is currently in progress.
-2. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
+1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
+2. Post-quantum resistant key exchange in double ratchet protocol.
3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
5. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
@@ -365,22 +364,26 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Message editing history
- ✅ Reduced battery and traffic usage in large groups.
- ✅ Message delivery confirmation (with sender opt-out per contact).
-- 🏗 Desktop client.
+- ✅ Desktop client.
+- ✅ Encryption of local files stored in the app.
+- 🏗 Using mobile profiles from the desktop app.
+- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
+- Post-quantum resistant key exchange in double ratchet protocol.
+- Large groups, communities and public channels.
+- Privacy & security slider - a simple way to set all settings at once.
+- Improve sending videos (including encryption of locally stored videos).
+- Improve experience for the new users.
- SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address.
-- Local app files encryption.
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
-- Large groups, communities and public channels.
- Feeds/broadcasts.
- Ephemeral/disappearing/OTR conversations with the existing contacts.
- Privately share your location.
- Web widgets for custom interactivity in the chats.
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
-- Supporting the same profile on multiple devices.
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
- keep all your contacts and groups even if you lose the domain.
- the server doesn't have information about your contacts and groups.
-- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- High capacity multi-node SMP relays.
## Disclaimers
diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift
index f2e67c4aa5..3dbcf47004 100644
--- a/apps/ios/Shared/ContentView.swift
+++ b/apps/ios/Shared/ContentView.swift
@@ -28,6 +28,17 @@ struct ContentView: View {
@State private var showWhatsNew = false
@State private var showChooseLAMode = false
@State private var showSetPasscode = false
+ @State private var chatListActionSheet: ChatListActionSheet? = nil
+
+ private enum ChatListActionSheet: Identifiable {
+ case connectViaUrl(action: ConnReqType, link: String)
+
+ var id: String {
+ switch self {
+ case let .connectViaUrl(_, link): return "connectViaUrl \(link)"
+ }
+ }
+ }
var body: some View {
ZStack {
@@ -80,6 +91,11 @@ struct ContentView: View {
if case .onboardingComplete = step,
chatModel.currentUser != nil {
mainView()
+ .actionSheet(item: $chatListActionSheet) { sheet in
+ switch sheet {
+ case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link)
+ }
+ }
} else {
OnboardingView(onboarding: step)
}
@@ -132,7 +148,9 @@ struct ContentView: View {
}
}
prefShowLANotice = true
+ connectViaUrl()
}
+ .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.sheet(isPresented: $showWhatsNew) {
WhatsNewView()
}
@@ -265,36 +283,40 @@ struct ContentView: View {
secondaryButton: .cancel()
)
}
-}
-func connectViaUrl() {
- let m = ChatModel.shared
- if let url = m.appOpenUrl {
- m.appOpenUrl = nil
- AlertManager.shared.showAlert(connectViaUrlAlert(url))
+ func connectViaUrl() {
+ dismissAllSheets() {
+ let m = ChatModel.shared
+ if let url = m.appOpenUrl {
+ m.appOpenUrl = nil
+ var path = url.path
+ logger.debug("ContentView.connectViaUrl path: \(path)")
+ if (path == "/contact" || path == "/invitation") {
+ path.removeFirst()
+ let action: ConnReqType = path == "contact" ? .contact : .invitation
+ let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
+ chatListActionSheet = .connectViaUrl(action: action, link: link)
+ } else {
+ AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
+ }
+ }
+ }
}
-}
-func connectViaUrlAlert(_ url: URL) -> Alert {
- var path = url.path
- logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
- if (path == "/contact" || path == "/invitation") {
- path.removeFirst()
- let action: ConnReqType = path == "contact" ? .contact : .invitation
- let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
+ private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet {
let title: LocalizedStringKey
- if case .contact = action { title = "Connect via contact link?" }
- else { title = "Connect via one-time link?" }
- return Alert(
+ switch action {
+ case .contact: title = "Connect via contact link"
+ case .invitation: title = "Connect via one-time link"
+ }
+ return ActionSheet(
title: Text(title),
- message: Text("Your profile will be sent to the contact that you received this link from"),
- primaryButton: .default(Text("Connect")) {
- connectViaLink(link)
- },
- secondaryButton: .cancel()
+ buttons: [
+ .default(Text("Use current profile")) { connectViaLink(link, incognito: false) },
+ .default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) },
+ .cancel()
+ ]
)
- } else {
- return Alert(title: Text("Error: URL is invalid"))
}
}
diff --git a/apps/ios/Shared/Model/AudioRecPlay.swift b/apps/ios/Shared/Model/AudioRecPlay.swift
index 698799457e..973d79ab3c 100644
--- a/apps/ios/Shared/Model/AudioRecPlay.swift
+++ b/apps/ios/Shared/Model/AudioRecPlay.swift
@@ -103,9 +103,15 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
self.onFinishPlayback = onFinishPlayback
}
- func start(fileName: String, at: TimeInterval?) {
- let url = getAppFilePath(fileName)
- audioPlayer = try? AVAudioPlayer(contentsOf: url)
+ func start(fileSource: CryptoFile, at: TimeInterval?) {
+ let url = getAppFilePath(fileSource.filePath)
+ if let cfArgs = fileSource.cryptoArgs {
+ if let data = try? readCryptoFile(path: url.path, cryptoArgs: cfArgs) {
+ audioPlayer = try? AVAudioPlayer(data: data)
+ }
+ } else {
+ audioPlayer = try? AVAudioPlayer(contentsOf: url)
+ }
audioPlayer?.delegate = self
audioPlayer?.prepareToPlay()
if let at = at {
diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift
index 3179b4f862..a1ec2f8f53 100644
--- a/apps/ios/Shared/Model/ChatModel.swift
+++ b/apps/ios/Shared/Model/ChatModel.swift
@@ -11,6 +11,38 @@ import Combine
import SwiftUI
import SimpleXChat
+actor TerminalItems {
+ private var terminalItems: [TerminalItem] = []
+
+ static let shared = TerminalItems()
+
+ func items() -> [TerminalItem] {
+ terminalItems
+ }
+
+ func add(_ item: TerminalItem) async {
+ addTermItem(&terminalItems, item)
+ let m = ChatModel.shared
+ if m.showingTerminal {
+ await MainActor.run {
+ addTermItem(&m.terminalItems, item)
+ }
+ }
+ }
+
+ func addCommand(_ start: Date, _ cmd: ChatCommand, _ resp: ChatResponse) async {
+ await add(.cmd(start, cmd))
+ await add(.resp(.now, resp))
+ }
+}
+
+private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
+ if items.count >= 200 {
+ items.removeFirst()
+ }
+ items.append(item)
+}
+
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@@ -33,6 +65,7 @@ final class ChatModel: ObservableObject {
@Published var chatToTop: String?
@Published var groupMembers: [GroupMember] = []
// items in the terminal view
+ @Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: UserContactLink?
@Published var chatItemTTL: ChatItemTTL = .none
@@ -43,9 +76,8 @@ final class ChatModel: ObservableObject {
@Published var tokenStatus: NtfTknStatus?
@Published var notificationMode = NotificationsMode.off
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
- @Published var incognito: Bool = incognitoGroupDefault.get()
// pending notification actions
- @Published var ntfContactRequest: ChatId?
+ @Published var ntfContactRequest: NTFContactRequest?
@Published var ntfCallInvitationAction: (ChatId, NtfCallAction)?
// current WebRTC call
@Published var callInvitations: Dictionary = [:]
@@ -463,18 +495,18 @@ final class ChatModel: ObservableObject {
}
}
- func increaseUnreadCounter(user: User) {
+ func increaseUnreadCounter(user: any UserLike) {
changeUnreadCounter(user: user, by: 1)
NtfManager.shared.incNtfBadgeCount()
}
- func decreaseUnreadCounter(user: User, by: Int = 1) {
+ func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
changeUnreadCounter(user: user, by: -by)
NtfManager.shared.decNtfBadgeCount(by: by)
}
- private func changeUnreadCounter(user: User, by: Int) {
- if let i = users.firstIndex(where: { $0.user.id == user.id }) {
+ private func changeUnreadCounter(user: any UserLike, by: Int) {
+ if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
users[i].unreadCount += by
}
}
@@ -484,14 +516,27 @@ final class ChatModel: ObservableObject {
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
}
- func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
- if let i = getChatItemIndex(ci), i < reversedChatItems.count - 1 {
- return reversedChatItems[i + 1]
+ func getConnectedMemberNames(_ ci: ChatItem) -> [String] {
+ guard var i = getChatItemIndex(ci) else { return [] }
+ var ns: [String] = []
+ while i < reversedChatItems.count, let m = reversedChatItems[i].memberConnected {
+ ns.append(m.displayName)
+ i += 1
+ }
+ return ns
+ }
+
+ func getChatItemNeighbors(_ ci: ChatItem) -> (ChatItem?, ChatItem?) {
+ if let i = getChatItemIndex(ci) {
+ return (
+ i + 1 < reversedChatItems.count ? reversedChatItems[i + 1] : nil,
+ i - 1 >= 0 ? reversedChatItems[i - 1] : nil
+ )
} else {
- return nil
+ return (nil, nil)
}
}
-
+
func popChat(_ id: String) {
if let i = getChatIndex(id) {
popChat_(i)
@@ -580,13 +625,11 @@ final class ChatModel: ObservableObject {
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
networkStatuses[contact.activeConn.agentConnId] ?? .unknown
}
+}
- func addTerminalItem(_ item: TerminalItem) {
- if terminalItems.count >= 500 {
- terminalItems.remove(at: 0)
- }
- terminalItems.append(item)
- }
+struct NTFContactRequest {
+ var incognito: Bool
+ var chatId: String
}
struct UnreadChatItemCounts {
diff --git a/apps/ios/Shared/Model/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift
index 4987f5a6f7..90070e74d3 100644
--- a/apps/ios/Shared/Model/ImageUtils.swift
+++ b/apps/ios/Shared/Model/ImageUtils.swift
@@ -11,42 +11,43 @@ import SimpleXChat
import SwiftUI
import AVKit
-func getLoadedFilePath(_ file: CIFile?) -> String? {
- if let fileName = getLoadedFileName(file) {
- return getAppFilePath(fileName).path
- }
- return nil
-}
-
-func getLoadedFileName(_ file: CIFile?) -> String? {
- if let file = file,
- file.loaded,
- let fileName = file.filePath {
- return fileName
+func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
+ if let file = file, file.loaded {
+ return file.fileSource
}
return nil
}
func getLoadedImage(_ file: CIFile?) -> UIImage? {
- let loadedFilePath = getLoadedFilePath(file)
- if let loadedFilePath = loadedFilePath, let fileName = file?.filePath {
- let filePath = getAppFilePath(fileName)
+ if let fileSource = getLoadedFileSource(file) {
+ let filePath = getAppFilePath(fileSource.filePath)
do {
- let data = try Data(contentsOf: filePath)
+ let data = try getFileData(filePath, fileSource.cryptoArgs)
let img = UIImage(data: data)
- try img?.setGifFromData(data, levelOfIntegrity: 1.0)
- return img
+ do {
+ try img?.setGifFromData(data, levelOfIntegrity: 1.0)
+ return img
+ } catch {
+ return UIImage(data: data)
+ }
} catch {
- return UIImage(contentsOfFile: loadedFilePath)
+ return nil
}
}
return nil
}
+func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
+ if let cfArgs = cfArgs {
+ return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
+ } else {
+ return try Data(contentsOf: path)
+ }
+}
+
func getLoadedVideo(_ file: CIFile?) -> URL? {
- let loadedFilePath = getLoadedFilePath(file)
- if loadedFilePath != nil, let fileName = file?.filePath {
- let filePath = getAppFilePath(fileName)
+ if let fileSource = getLoadedFileSource(file) {
+ let filePath = getAppFilePath(fileSource.filePath)
if FileManager.default.fileExists(atPath: filePath.path) {
return filePath
}
@@ -54,18 +55,18 @@ func getLoadedVideo(_ file: CIFile?) -> URL? {
return nil
}
-func saveAnimImage(_ image: UIImage) -> String? {
+func saveAnimImage(_ image: UIImage) -> CryptoFile? {
let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil }
- return saveFile(imageData, fileName)
+ return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
}
-func saveImage(_ uiImage: UIImage) -> String? {
+func saveImage(_ uiImage: UIImage) -> CryptoFile? {
let hasAlpha = imageHasAlpha(uiImage)
let ext = hasAlpha ? "png" : "jpg"
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
let fileName = generateNewFileName("IMG", ext)
- return saveFile(imageDataResized, fileName)
+ return saveFile(imageDataResized, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
}
return nil
}
@@ -157,13 +158,19 @@ func imageHasAlpha(_ img: UIImage) -> Bool {
return false
}
-func saveFileFromURL(_ url: URL) -> String? {
- let savedFile: String?
+func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? {
+ let savedFile: CryptoFile?
if url.startAccessingSecurityScopedResource() {
do {
- let fileData = try Data(contentsOf: url)
let fileName = uniqueCombine(url.lastPathComponent)
- savedFile = saveFile(fileData, fileName)
+ let toPath = getAppFilePath(fileName).path
+ if encrypted {
+ let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: toPath)
+ savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs)
+ } else {
+ try FileManager.default.copyItem(atPath: url.path, toPath: toPath)
+ savedFile = CryptoFile.plain(fileName)
+ }
} catch {
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
savedFile = nil
@@ -176,18 +183,16 @@ func saveFileFromURL(_ url: URL) -> String? {
return savedFile
}
-func saveFileFromURLWithoutLoad(_ url: URL) -> String? {
- let savedFile: String?
+func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
do {
let fileName = uniqueCombine(url.lastPathComponent)
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
ChatModel.shared.filesToDelete.remove(url)
- savedFile = fileName
+ return CryptoFile.plain(fileName)
} catch {
- logger.error("FileUtils.saveFileFromURLWithoutLoad error: \(error.localizedDescription)")
- savedFile = nil
+ logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
+ return nil
}
- return savedFile
}
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
@@ -288,4 +293,4 @@ extension UIImage {
}
return self
}
-}
\ No newline at end of file
+}
diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift
index ad41703c91..f1fdcc018e 100644
--- a/apps/ios/Shared/Model/NtfManager.swift
+++ b/apps/ios/Shared/Model/NtfManager.swift
@@ -12,6 +12,7 @@ import UIKit
import SimpleXChat
let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT"
+let ntfActionAcceptContactIncognito = "NTF_ACT_ACCEPT_CONTACT_INCOGNITO"
let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL"
let ntfActionRejectCall = "NTF_ACT_REJECT_CALL"
@@ -41,12 +42,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
userId != chatModel.currentUser?.userId {
changeActiveUser(userId, viewPwd: nil)
}
- if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
+ if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
let chatId = content.userInfo["chatId"] as? String {
+ let incognito = action == ntfActionAcceptContactIncognito
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
- Task { await acceptContactRequest(contactRequest) }
+ Task { await acceptContactRequest(incognito: incognito, contactRequest: contactRequest) }
} else {
- chatModel.ntfContactRequest = chatId
+ chatModel.ntfContactRequest = NTFContactRequest(incognito: incognito, chatId: chatId)
}
} else if let (chatId, ntfAction) = ntfCallAction(content, action) {
if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) {
@@ -134,11 +136,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
UNUserNotificationCenter.current().setNotificationCategories([
UNNotificationCategory(
identifier: ntfCategoryContactRequest,
- actions: [UNNotificationAction(
- identifier: ntfActionAcceptContact,
- title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
- options: .foreground
- )],
+ actions: [
+ UNNotificationAction(
+ identifier: ntfActionAcceptContact,
+ title: NSLocalizedString("Accept", comment: "accept contact request via notification"),
+ options: .foreground
+ ), UNNotificationAction(
+ identifier: ntfActionAcceptContactIncognito,
+ title: NSLocalizedString("Accept incognito", comment: "accept contact request via notification"),
+ options: .foreground
+ )
+ ],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification")
),
@@ -203,17 +211,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
center.delegate = self
}
- func notifyContactRequest(_ user: User, _ contactRequest: UserContactRequest) {
+ func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest")
addNotification(createContactRequestNtf(user, contactRequest))
}
- func notifyContactConnected(_ user: User, _ contact: Contact) {
+ func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected")
addNotification(createContactConnectedNtf(user, contact))
}
- func notifyMessageReceived(_ user: User, _ cInfo: ChatInfo, _ cItem: ChatItem) {
+ func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
if cInfo.ntfsEnabled {
addNotification(createMessageReceivedNtf(user, cInfo, cItem))
diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift
index 74395302a8..aef8711f34 100644
--- a/apps/ios/Shared/Model/SimpleXAPI.swift
+++ b/apps/ios/Shared/Model/SimpleXAPI.swift
@@ -15,6 +15,12 @@ import SimpleXChat
private var chatController: chat_ctrl?
+// currentChatVersion in core
+public let CURRENT_CHAT_VERSION: Int = 2
+
+// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core)
+public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION)
+
enum TerminalItem: Identifiable {
case cmd(Date, ChatCommand)
case resp(Date, ChatResponse)
@@ -94,9 +100,8 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
}
- DispatchQueue.main.async {
- ChatModel.shared.addTerminalItem(.cmd(start, cmd.obfuscated))
- ChatModel.shared.addTerminalItem(.resp(.now, resp))
+ Task {
+ await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
}
return resp
}
@@ -171,6 +176,12 @@ func apiSetUserContactReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgR
throw r
}
+func apiSetUserGroupReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws {
+ let r = await chatSendCmd(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
+ if case .cmdOk = r { return }
+ throw r
+}
+
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd))
}
@@ -246,12 +257,6 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
throw r
}
-func apiSetIncognito(incognito: Bool) throws {
- let r = chatSendCmdSync(.setIncognito(incognito: incognito))
- if case .cmdOk = r { return }
- throw r
-}
-
func apiExportArchive(config: ArchiveConfig) async throws {
try await sendCommandOkResp(.apiExportArchive(config: config))
}
@@ -316,17 +321,23 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
throw r
}
-func apiSendMessage(type: ChatType, id: Int64, file: String?, 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 chatModel = ChatModel.shared
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
let r: ChatResponse
if type == .direct {
- var cItem: ChatItem!
- let endTask = beginBGTask({ if cItem != nil { chatModel.messageDelivery.removeValue(forKey: cItem.id) } })
+ var cItem: ChatItem? = nil
+ let endTask = beginBGTask({
+ if let cItem = cItem {
+ DispatchQueue.main.async {
+ chatModel.messageDelivery.removeValue(forKey: cItem.id)
+ }
+ }
+ })
r = await chatSendCmd(cmd, bgTask: false)
if case let .newChatItem(_, aChatItem) = r {
cItem = aChatItem.chatItem
- chatModel.messageDelivery[cItem.id] = endTask
+ chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
return cItem
}
if let networkErrorAlert = networkErrorAlert(r) {
@@ -558,19 +569,25 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo
return nil
}
-func apiAddContact() async -> String? {
+func apiAddContact(incognito: Bool) async -> (String, PendingContactConnection)? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiAddContact: no current user")
return nil
}
- let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false)
- if case let .invitation(_, connReqInvitation) = r { return connReqInvitation }
+ let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false)
+ if case let .invitation(_, connReqInvitation, connection) = r { return (connReqInvitation, connection) }
AlertManager.shared.showAlert(connectionErrorAlert(r))
return nil
}
-func apiConnect(connReq: String) async -> ConnReqType? {
- let (connReqType, alert) = await apiConnect_(connReq: connReq)
+func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? {
+ let r = await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito))
+ if case let .connectionIncognitoUpdated(_, toConnection) = r { return toConnection }
+ throw r
+}
+
+func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
+ let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
if let alert = alert {
AlertManager.shared.showAlert(alert)
return nil
@@ -579,12 +596,12 @@ func apiConnect(connReq: String) async -> ConnReqType? {
}
}
-func apiConnect_(connReq: String) async -> (ConnReqType?, Alert?) {
+func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnect: no current user")
return (nil, nil)
}
- let r = await chatSendCmd(.apiConnect(userId: userId, connReq: connReq))
+ let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq))
switch r {
case .sentConfirmation: return (.invitation, nil)
case .sentInvitation: return (.contact, nil)
@@ -680,12 +697,12 @@ func apiListContacts() throws -> [Contact] {
throw r
}
-func apiUpdateProfile(profile: Profile) async throws -> Profile? {
+func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? {
let userId = try currentUserId("apiUpdateProfile")
let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile))
switch r {
case .userProfileNoChange: return nil
- case let .userProfileUpdated(_, _, toProfile): return toProfile
+ case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts)
default: throw r
}
}
@@ -695,7 +712,7 @@ func apiSetProfileAddress(on: Bool) async throws -> User? {
let r = await chatSendCmd(.apiSetProfileAddress(userId: userId, on: on))
switch r {
case .userProfileNoChange: return nil
- case let .userProfileUpdated(user, _, _): return user
+ case let .userProfileUpdated(user, _, _, _): return user
default: throw r
}
}
@@ -760,8 +777,8 @@ func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContac
}
}
-func apiAcceptContactRequest(contactReqId: Int64) async -> Contact? {
- let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
+func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? {
+ let r = await chatSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId))
let am = AlertManager.shared
if case let .acceptingContactRequest(_, contact) = r { return contact }
@@ -796,29 +813,35 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
}
-func receiveFile(user: User, fileId: Int64) async {
- if let chatItem = await apiReceiveFile(fileId: fileId) {
- DispatchQueue.main.async { chatItemSimpleUpdate(user, chatItem) }
+func receiveFile(user: any UserLike, fileId: Int64, encrypted: Bool, auto: Bool = false) async {
+ if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: encrypted, auto: auto) {
+ await chatItemSimpleUpdate(user, chatItem)
}
}
-func apiReceiveFile(fileId: Int64, inline: Bool? = nil) async -> AChatItem? {
- let r = await chatSendCmd(.receiveFile(fileId: fileId, inline: inline))
+func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
+ let r = await chatSendCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline))
let am = AlertManager.shared
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
if case .rcvFileAcceptedSndCancelled = r {
- am.showAlertMsg(
- title: "Cannot receive file",
- message: "Sender cancelled file transfer."
- )
+ logger.debug("apiReceiveFile error: sender cancelled file transfer")
+ if !auto {
+ am.showAlertMsg(
+ title: "Cannot receive file",
+ message: "Sender cancelled file transfer."
+ )
+ }
} else if let networkErrorAlert = networkErrorAlert(r) {
+ logger.error("apiReceiveFile network error: \(String(describing: r))")
am.showAlert(networkErrorAlert)
} else {
- logger.error("apiReceiveFile error: \(String(describing: r))")
- switch r {
- case .chatCmdError(_, .error(.fileAlreadyReceiving)):
+ switch chatError(r) {
+ case .fileCancelled:
+ logger.debug("apiReceiveFile ignoring fileCancelled error")
+ case .fileAlreadyReceiving:
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
default:
+ logger.error("apiReceiveFile error: \(String(describing: r))")
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(String(describing: r))"
@@ -830,7 +853,7 @@ func apiReceiveFile(fileId: Int64, inline: Bool? = nil) async -> AChatItem? {
func cancelFile(user: User, fileId: Int64) async {
if let chatItem = await apiCancelFile(fileId: fileId) {
- DispatchQueue.main.async { chatItemSimpleUpdate(user, chatItem) }
+ await chatItemSimpleUpdate(user, chatItem)
cleanupFile(chatItem)
}
}
@@ -863,8 +886,8 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
}
}
-func acceptContactRequest(_ contactRequest: UserContactRequest) async {
- if let contact = await apiAcceptContactRequest(contactReqId: contactRequest.apiId) {
+func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
+ if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
}
@@ -1073,6 +1096,18 @@ func apiGetGroupLink(_ groupId: Int64) throws -> (String, GroupMemberRole)? {
}
}
+func apiCreateMemberContact(_ groupId: Int64, _ groupMemberId: Int64) async throws -> Contact {
+ let r = await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId))
+ if case let .newMemberContact(_, contact, _, _) = r { return contact }
+ throw r
+}
+
+func apiSendMemberContactInvitation(_ contactId: Int64, _ msg: MsgContent) async throws -> Contact {
+ let r = await chatSendCmd(.apiSendMemberContactInvitation(contactId: contactId, msg: msg), bgDelay: msgDelay)
+ if case let .newMemberContactSentInv(_, contact, _, _) = r { return contact }
+ throw r
+}
+
func apiGetVersion() throws -> CoreVersionInfo {
let r = chatSendCmdSync(.showVersion)
if case let .versionInfo(info, _, _) = r { return info }
@@ -1098,7 +1133,6 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try setXFTPConfig(getXFTPCfg())
- try apiSetIncognito(incognito: incognitoGroupDefault.get())
m.chatInitialized = true
m.currentUser = try apiGetActiveUser()
if m.currentUser == nil {
@@ -1233,38 +1267,50 @@ class ChatReceiver {
}
func processReceivedMsg(_ res: ChatResponse) async {
+ Task {
+ await TerminalItems.shared.add(.resp(.now, res))
+ }
let m = ChatModel.shared
- await MainActor.run {
- m.addTerminalItem(.resp(.now, res))
- logger.debug("processReceivedMsg: \(res.responseType)")
- switch res {
- case let .newContactConnection(user, connection):
- if active(user) {
+ logger.debug("processReceivedMsg: \(res.responseType)")
+ switch res {
+ case let .newContactConnection(user, connection):
+ if active(user) {
+ await MainActor.run {
m.updateContactConnection(connection)
}
- case let .contactConnectionDeleted(user, connection):
- if active(user) {
+ }
+ case let .contactConnectionDeleted(user, connection):
+ if active(user) {
+ await MainActor.run {
m.removeChat(connection.id)
}
- case let .contactConnected(user, contact, _):
- if active(user) && contact.directOrUsed {
+ }
+ case let .contactConnected(user, contact, _):
+ if active(user) && contact.directOrUsed {
+ await MainActor.run {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
}
- if contact.directOrUsed {
- NtfManager.shared.notifyContactConnected(user, contact)
- }
+ }
+ if contact.directOrUsed {
+ NtfManager.shared.notifyContactConnected(user, contact)
+ }
+ await MainActor.run {
m.setContactNetworkStatus(contact, .connected)
- case let .contactConnecting(user, contact):
- if active(user) && contact.directOrUsed {
+ }
+ case let .contactConnecting(user, contact):
+ if active(user) && contact.directOrUsed {
+ await MainActor.run {
m.updateContact(contact)
m.dismissConnReqView(contact.activeConn.id)
m.removeChat(contact.activeConn.id)
}
- case let .receivedContactRequest(user, contactRequest):
- if active(user) {
- let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
+ }
+ case let .receivedContactRequest(user, contactRequest):
+ if active(user) {
+ let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
+ await MainActor.run {
if m.hasChat(contactRequest.id) {
m.updateChatInfo(cInfo)
} else {
@@ -1274,255 +1320,318 @@ func processReceivedMsg(_ res: ChatResponse) async {
))
}
}
- NtfManager.shared.notifyContactRequest(user, contactRequest)
- case let .contactUpdated(user, toContact):
- if active(user) && m.hasChat(toContact.id) {
+ }
+ NtfManager.shared.notifyContactRequest(user, contactRequest)
+ case let .contactUpdated(user, toContact):
+ if active(user) && m.hasChat(toContact.id) {
+ await MainActor.run {
let cInfo = ChatInfo.direct(contact: toContact)
m.updateChatInfo(cInfo)
}
- case let .contactsMerged(user, intoContact, mergedContact):
- if active(user) && m.hasChat(mergedContact.id) {
+ }
+ case let .contactsMerged(user, intoContact, mergedContact):
+ if active(user) && m.hasChat(mergedContact.id) {
+ await MainActor.run {
if m.chatId == mergedContact.id {
m.chatId = intoContact.id
}
m.removeChat(mergedContact.id)
}
- case let .contactsSubscribed(_, contactRefs):
- updateContactsStatus(contactRefs, status: .connected)
- case let .contactsDisconnected(_, contactRefs):
- updateContactsStatus(contactRefs, status: .disconnected)
- case let .contactSubError(user, contact, chatError):
+ }
+ case let .contactsSubscribed(_, contactRefs):
+ await updateContactsStatus(contactRefs, status: .connected)
+ case let .contactsDisconnected(_, contactRefs):
+ await updateContactsStatus(contactRefs, status: .disconnected)
+ case let .contactSubError(user, contact, chatError):
+ await MainActor.run {
if active(user) {
m.updateContact(contact)
}
processContactSubError(contact, chatError)
- case let .contactSubSummary(user, contactSubscriptions):
+ }
+ case let .contactSubSummary(_, contactSubscriptions):
+ await MainActor.run {
for sub in contactSubscriptions {
- if active(user) {
- m.updateContact(sub.contact)
- }
+// no need to update contact here, and it is slow
+// if active(user) {
+// m.updateContact(sub.contact)
+// }
if let err = sub.contactError {
processContactSubError(sub.contact, err)
} else {
m.setContactNetworkStatus(sub.contact, .connected)
}
}
- case let .newChatItem(user, aChatItem):
- let cInfo = aChatItem.chatInfo
- let cItem = aChatItem.chatItem
+ }
+ case let .newChatItem(user, aChatItem):
+ let cInfo = aChatItem.chatInfo
+ let cItem = aChatItem.chatItem
+ await MainActor.run {
if active(user) {
m.addChatItem(cInfo, cItem)
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
}
- if let file = cItem.autoReceiveFile() {
- Task {
- await receiveFile(user: user, fileId: file.fileId)
- }
+ }
+ if let file = cItem.autoReceiveFile() {
+ Task {
+ await receiveFile(user: user, fileId: file.fileId, encrypted: cItem.encryptLocalFile, auto: true)
}
- if cItem.showNotification {
+ }
+ if cItem.showNotification {
+ NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
+ }
+ case let .chatItemStatusUpdated(user, aChatItem):
+ let cInfo = aChatItem.chatInfo
+ let cItem = aChatItem.chatItem
+ if !cItem.isDeletedContent {
+ let added = active(user) ? await MainActor.run { m.upsertChatItem(cInfo, cItem) } : true
+ if added && cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
- case let .chatItemStatusUpdated(user, aChatItem):
- let cInfo = aChatItem.chatInfo
- let cItem = aChatItem.chatItem
- if !cItem.isDeletedContent {
- let added = active(user) ? m.upsertChatItem(cInfo, cItem) : true
- if added && cItem.showNotification {
- NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
- }
+ }
+ if let endTask = m.messageDelivery[cItem.id] {
+ switch cItem.meta.itemStatus {
+ case .sndSent: endTask()
+ case .sndErrorAuth: endTask()
+ case .sndError: endTask()
+ default: ()
}
- if let endTask = m.messageDelivery[cItem.id] {
- switch cItem.meta.itemStatus {
- case .sndSent: endTask()
- case .sndErrorAuth: endTask()
- case .sndError: endTask()
- default: ()
- }
- }
- case let .chatItemUpdated(user, aChatItem):
- chatItemSimpleUpdate(user, aChatItem)
- case let .chatItemReaction(user, _, r):
- if active(user) {
+ }
+ case let .chatItemUpdated(user, aChatItem):
+ await chatItemSimpleUpdate(user, aChatItem)
+ case let .chatItemReaction(user, _, r):
+ if active(user) {
+ await MainActor.run {
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
}
- case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
- if !active(user) {
- if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
+ }
+ case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
+ if !active(user) {
+ if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
+ await MainActor.run {
m.decreaseUnreadCounter(user: user)
}
- return
}
+ return
+ }
+ await MainActor.run {
if let toChatItem = toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
}
- case let .receivedGroupInvitation(user, groupInfo, _, _):
- if active(user) {
+ }
+ case let .receivedGroupInvitation(user, groupInfo, _, _):
+ if active(user) {
+ await MainActor.run {
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
// NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation?
}
- case let .userAcceptedGroupSent(user, groupInfo, hostContact):
- if !active(user) { return }
+ }
+ case let .userAcceptedGroupSent(user, groupInfo, hostContact):
+ if !active(user) { return }
+ await MainActor.run {
m.updateGroup(groupInfo)
if let hostContact = hostContact {
m.dismissConnReqView(hostContact.activeConn.id)
m.removeChat(hostContact.activeConn.id)
}
- case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
- if active(user) {
+ }
+ case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
+ if active(user) {
+ await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
- case let .deletedMemberUser(user, groupInfo, _): // TODO update user member
- if active(user) {
+ }
+ case let .deletedMemberUser(user, groupInfo, _): // TODO update user member
+ if active(user) {
+ await MainActor.run {
m.updateGroup(groupInfo)
}
- case let .deletedMember(user, groupInfo, _, deletedMember):
- if active(user) {
+ }
+ case let .deletedMember(user, groupInfo, _, deletedMember):
+ if active(user) {
+ await MainActor.run {
_ = m.upsertGroupMember(groupInfo, deletedMember)
}
- case let .leftMember(user, groupInfo, member):
- if active(user) {
+ }
+ case let .leftMember(user, groupInfo, member):
+ if active(user) {
+ await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
- case let .groupDeleted(user, groupInfo, _): // TODO update user member
- if active(user) {
+ }
+ case let .groupDeleted(user, groupInfo, _): // TODO update user member
+ if active(user) {
+ await MainActor.run {
m.updateGroup(groupInfo)
}
- case let .userJoinedGroup(user, groupInfo):
- if active(user) {
+ }
+ case let .userJoinedGroup(user, groupInfo):
+ if active(user) {
+ await MainActor.run {
m.updateGroup(groupInfo)
}
- case let .joinedGroupMember(user, groupInfo, member):
- if active(user) {
+ }
+ case let .joinedGroupMember(user, groupInfo, member):
+ if active(user) {
+ await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
- case let .connectedToGroupMember(user, groupInfo, member, memberContact):
- if active(user) {
+ }
+ case let .connectedToGroupMember(user, groupInfo, member, memberContact):
+ if active(user) {
+ await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
- if let contact = memberContact {
+ }
+ if let contact = memberContact {
+ await MainActor.run {
m.setContactNetworkStatus(contact, .connected)
}
- case let .groupUpdated(user, toGroup):
- if active(user) {
+ }
+ case let .groupUpdated(user, toGroup):
+ if active(user) {
+ await MainActor.run {
m.updateGroup(toGroup)
}
- case let .memberRole(user, groupInfo, _, _, _, _):
- if active(user) {
+ }
+ case let .memberRole(user, groupInfo, _, _, _, _):
+ if active(user) {
+ await MainActor.run {
m.updateGroup(groupInfo)
}
- case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE
- chatItemSimpleUpdate(user, aChatItem)
- case let .rcvFileStart(user, aChatItem):
- chatItemSimpleUpdate(user, aChatItem)
- case let .rcvFileComplete(user, aChatItem):
- chatItemSimpleUpdate(user, aChatItem)
- case let .rcvFileSndCancelled(user, aChatItem, _):
- chatItemSimpleUpdate(user, aChatItem)
- cleanupFile(aChatItem)
- case let .rcvFileProgressXFTP(user, aChatItem, _, _):
- chatItemSimpleUpdate(user, aChatItem)
- case let .rcvFileError(user, aChatItem):
- chatItemSimpleUpdate(user, aChatItem)
- cleanupFile(aChatItem)
- case let .sndFileStart(user, aChatItem, _):
- chatItemSimpleUpdate(user, aChatItem)
- case let .sndFileComplete(user, aChatItem, _):
- chatItemSimpleUpdate(user, aChatItem)
- cleanupDirectFile(aChatItem)
- case let .sndFileRcvCancelled(user, aChatItem, _):
- chatItemSimpleUpdate(user, aChatItem)
- cleanupDirectFile(aChatItem)
- case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
- chatItemSimpleUpdate(user, aChatItem)
- case let .sndFileCompleteXFTP(user, aChatItem, _):
- chatItemSimpleUpdate(user, aChatItem)
- cleanupFile(aChatItem)
- case let .sndFileError(user, aChatItem):
- chatItemSimpleUpdate(user, aChatItem)
- cleanupFile(aChatItem)
- case let .callInvitation(invitation):
- m.callInvitations[invitation.contact.id] = invitation
- activateCall(invitation)
- case let .callOffer(_, contact, callType, offer, sharedKey, _):
- withCall(contact) { call in
- call.callState = .offerReceived
- call.peerMedia = callType.media
- call.sharedKey = sharedKey
- let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
- let iceServers = getIceServers()
- logger.debug(".callOffer useRelay \(useRelay)")
- logger.debug(".callOffer iceServers \(String(describing: iceServers))")
- m.callCommand = .offer(
- offer: offer.rtcSession,
- iceCandidates: offer.rtcIceCandidates,
- media: callType.media, aesKey: sharedKey,
- iceServers: iceServers,
- relay: useRelay
- )
- }
- case let .callAnswer(_, contact, answer):
- withCall(contact) { call in
- call.callState = .answerReceived
- m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
- }
- case let .callExtraInfo(_, contact, extraInfo):
- withCall(contact) { _ in
- m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
- }
- case let .callEnded(_, contact):
- if let invitation = m.callInvitations.removeValue(forKey: contact.id) {
- CallController.shared.reportCallRemoteEnded(invitation: invitation)
- }
- withCall(contact) { call in
- m.callCommand = .end
- CallController.shared.reportCallRemoteEnded(call: call)
- }
- case .chatSuspended:
- chatSuspended()
- case let .contactSwitch(_, contact, switchProgress):
- m.updateContactConnectionStats(contact, switchProgress.connectionStats)
- case let .groupMemberSwitch(_, groupInfo, member, switchProgress):
- m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
- case let .contactRatchetSync(_, contact, ratchetSyncProgress):
- m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
- case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress):
- m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
- default:
- logger.debug("unsupported event: \(res.responseType)")
}
-
- func withCall(_ contact: Contact, _ perform: (Call) -> Void) {
- if let call = m.activeCall, call.contact.apiId == contact.apiId {
- perform(call)
- } else {
- logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
+ case let .newMemberContactReceivedInv(user, contact, _, _):
+ if active(user) {
+ await MainActor.run {
+ m.updateContact(contact)
}
}
+ case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE
+ await chatItemSimpleUpdate(user, aChatItem)
+ case let .rcvFileStart(user, aChatItem):
+ await chatItemSimpleUpdate(user, aChatItem)
+ case let .rcvFileComplete(user, aChatItem):
+ await chatItemSimpleUpdate(user, aChatItem)
+ case let .rcvFileSndCancelled(user, aChatItem, _):
+ await chatItemSimpleUpdate(user, aChatItem)
+ Task { cleanupFile(aChatItem) }
+ case let .rcvFileProgressXFTP(user, aChatItem, _, _):
+ await chatItemSimpleUpdate(user, aChatItem)
+ case let .rcvFileError(user, aChatItem):
+ await chatItemSimpleUpdate(user, aChatItem)
+ Task { cleanupFile(aChatItem) }
+ case let .sndFileStart(user, aChatItem, _):
+ await chatItemSimpleUpdate(user, aChatItem)
+ case let .sndFileComplete(user, aChatItem, _):
+ await chatItemSimpleUpdate(user, aChatItem)
+ Task { cleanupDirectFile(aChatItem) }
+ case let .sndFileRcvCancelled(user, aChatItem, _):
+ await chatItemSimpleUpdate(user, aChatItem)
+ Task { cleanupDirectFile(aChatItem) }
+ case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
+ await chatItemSimpleUpdate(user, aChatItem)
+ case let .sndFileCompleteXFTP(user, aChatItem, _):
+ await chatItemSimpleUpdate(user, aChatItem)
+ Task { cleanupFile(aChatItem) }
+ case let .sndFileError(user, aChatItem):
+ await chatItemSimpleUpdate(user, aChatItem)
+ Task { cleanupFile(aChatItem) }
+ case let .callInvitation(invitation):
+ await MainActor.run {
+ m.callInvitations[invitation.contact.id] = invitation
+ }
+ activateCall(invitation)
+ case let .callOffer(_, contact, callType, offer, sharedKey, _):
+ await withCall(contact) { call in
+ call.callState = .offerReceived
+ call.peerMedia = callType.media
+ call.sharedKey = sharedKey
+ let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
+ let iceServers = getIceServers()
+ logger.debug(".callOffer useRelay \(useRelay)")
+ logger.debug(".callOffer iceServers \(String(describing: iceServers))")
+ m.callCommand = .offer(
+ offer: offer.rtcSession,
+ iceCandidates: offer.rtcIceCandidates,
+ media: callType.media, aesKey: sharedKey,
+ iceServers: iceServers,
+ relay: useRelay
+ )
+ }
+ case let .callAnswer(_, contact, answer):
+ await withCall(contact) { call in
+ call.callState = .answerReceived
+ m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates)
+ }
+ case let .callExtraInfo(_, contact, extraInfo):
+ await withCall(contact) { _ in
+ m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates)
+ }
+ case let .callEnded(_, contact):
+ if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
+ CallController.shared.reportCallRemoteEnded(invitation: invitation)
+ }
+ await withCall(contact) { call in
+ m.callCommand = .end
+ CallController.shared.reportCallRemoteEnded(call: call)
+ }
+ case .chatSuspended:
+ chatSuspended()
+ case let .contactSwitch(_, contact, switchProgress):
+ await MainActor.run {
+ m.updateContactConnectionStats(contact, switchProgress.connectionStats)
+ }
+ case let .groupMemberSwitch(_, groupInfo, member, switchProgress):
+ await MainActor.run {
+ m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
+ }
+ case let .contactRatchetSync(_, contact, ratchetSyncProgress):
+ await MainActor.run {
+ m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
+ }
+ case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress):
+ await MainActor.run {
+ m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
+ }
+ default:
+ logger.debug("unsupported event: \(res.responseType)")
+ }
+
+ func withCall(_ contact: Contact, _ perform: (Call) -> Void) async {
+ if let call = m.activeCall, call.contact.apiId == contact.apiId {
+ await MainActor.run { perform(call) }
+ } else {
+ logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
+ }
}
}
-func active(_ user: User) -> Bool {
- user.id == ChatModel.shared.currentUser?.id
+func active(_ user: any UserLike) -> Bool {
+ user.userId == ChatModel.shared.currentUser?.id
}
-func chatItemSimpleUpdate(_ user: User, _ aChatItem: AChatItem) {
+func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
let m = ChatModel.shared
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
- if active(user) && m.upsertChatItem(cInfo, cItem) {
- NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
+ if active(user) {
+ if await MainActor.run(body: { m.upsertChatItem(cInfo, cItem) }) {
+ NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
+ }
}
}
-func updateContactsStatus(_ contactRefs: [ContactRef], status: NetworkStatus) {
+func updateContactsStatus(_ contactRefs: [ContactRef], status: NetworkStatus) async {
let m = ChatModel.shared
- for c in contactRefs {
- m.networkStatuses[c.agentConnId] = status
+ await MainActor.run {
+ for c in contactRefs {
+ m.networkStatuses[c.agentConnId] = status
+ }
}
}
@@ -1561,7 +1670,9 @@ func activateCall(_ callInvitation: RcvCallInvitation) {
let m = ChatModel.shared
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
if let error = error {
- m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
+ DispatchQueue.main.async {
+ m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
+ }
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
} else {
logger.debug("reportNewIncomingCall success")
@@ -1573,15 +1684,3 @@ private struct UserResponse: Decodable {
var user: User?
var error: String?
}
-
-struct RuntimeError: Error {
- let message: String
-
- init(_ message: String) {
- self.message = message
- }
-
- public var localizedDescription: String {
- return message
- }
-}
diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift
index 5184808bd7..13e681ae25 100644
--- a/apps/ios/Shared/SimpleXApp.swift
+++ b/apps/ios/Shared/SimpleXApp.swift
@@ -139,10 +139,10 @@ struct SimpleXApp: App {
let chat = chatModel.getChat(id) {
loadChat(chat: chat)
}
- if let chatId = chatModel.ntfContactRequest {
+ if let ncr = chatModel.ntfContactRequest {
chatModel.ntfContactRequest = nil
- if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
- Task { await acceptContactRequest(contactRequest) }
+ if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
+ Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
}
}
} catch let error {
diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
index 5bbc64e165..ec4cb90097 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift
@@ -143,7 +143,12 @@ struct ChatInfoView: View {
if let customUserProfile = customUserProfile {
Section("Incognito") {
- infoRow("Your random profile", customUserProfile.chatViewName)
+ HStack {
+ Text("Your random profile")
+ Spacer()
+ Text(customUserProfile.chatViewName)
+ .foregroundStyle(.indigo)
+ }
}
}
@@ -159,6 +164,7 @@ struct ChatInfoView: View {
// synchronizeConnectionButtonForce()
// }
}
+ .disabled(!contact.ready)
if let contactLink = contact.contactLink {
Section {
@@ -175,30 +181,33 @@ struct ChatInfoView: View {
}
}
- Section("Servers") {
- networkStatusRow()
- .onTapGesture {
- alert = .networkStatusAlert
- }
- if let connStats = connectionStats {
- 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
+ if contact.ready {
+ Section("Servers") {
+ networkStatusRow()
+ .onTapGesture {
+ alert = .networkStatusAlert
+ }
+ if let connStats = connectionStats {
+ Button("Change receiving address") {
+ alert = .switchAddressAlert
}
.disabled(
- connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
+ !contact.ready
+ || connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
+ if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
+ Button("Abort changing address") {
+ alert = .abortSwitchAddressAlert
+ }
+ .disabled(
+ connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
+ || connStats.ratchetSyncSendProhibited
+ )
+ }
+ smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
+ smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
}
- smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
- smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
}
}
@@ -431,9 +440,9 @@ struct ChatInfoView: View {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
await MainActor.run {
- chatModel.removeChat(chat.chatInfo.id)
- chatModel.chatId = nil
dismiss()
+ chatModel.chatId = nil
+ chatModel.removeChat(chat.chatInfo.id)
}
} catch let error {
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift
index cd059b704c..9b372154ed 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift
@@ -10,20 +10,11 @@ import SwiftUI
import SimpleXChat
struct CIEventView: View {
- var chatItem: ChatItem
+ var eventText: Text
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
- if let member = chatItem.memberDisplayName {
- Text(member)
- .font(.caption)
- .foregroundColor(.secondary)
- .fontWeight(.light)
- + Text(" ")
- + chatEventText(chatItem)
- } else {
- chatEventText(chatItem)
- }
+ eventText
}
.padding(.leading, 6)
.padding(.bottom, 6)
@@ -31,20 +22,8 @@ struct CIEventView: View {
}
}
-func chatEventText(_ ci: ChatItem) -> Text {
- Text(ci.content.text)
- .font(.caption)
- .foregroundColor(.secondary)
- .fontWeight(.light)
- + Text(" ")
- + ci.timestampText
- .font(.caption)
- .foregroundColor(Color.secondary)
- .fontWeight(.light)
-}
-
struct CIEventView_Previews: PreviewProvider {
static var previews: some View {
- CIEventView(chatItem: ChatItem.getGroupEventSample())
+ CIEventView(eventText: Text("event happened"))
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
index 6e792e825d..359633a5f5 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift
@@ -16,8 +16,8 @@ struct CIFileView: View {
var body: some View {
let metaReserve = edited
- ? " "
- : " "
+ ? " "
+ : " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
@@ -84,7 +84,8 @@ struct CIFileView: View {
Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
if let user = ChatModel.shared.currentUser {
- await receiveFile(user: user, fileId: file.fileId)
+ let encrypted = privacyEncryptLocalFilesGroupDefault.get()
+ await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted)
}
}
} else {
@@ -109,9 +110,8 @@ struct CIFileView: View {
}
case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete")
- if let filePath = getLoadedFilePath(file) {
- let url = URL(fileURLWithPath: filePath)
- showShareSheet(items: [url])
+ if let fileSource = getLoadedFileSource(file) {
+ saveCryptoFile(fileSource)
}
default: break
}
@@ -193,11 +193,35 @@ struct CIFileView: View {
}
}
+func saveCryptoFile(_ fileSource: CryptoFile) {
+ if let cfArgs = fileSource.cryptoArgs {
+ let url = getAppFilePath(fileSource.filePath)
+ let tempUrl = getTempFilesDirectory().appendingPathComponent(fileSource.filePath)
+ Task {
+ do {
+ try decryptCryptoFile(fromPath: url.path, cryptoArgs: cfArgs, toPath: tempUrl.path)
+ await MainActor.run {
+ showShareSheet(items: [tempUrl]) {
+ removeFile(tempUrl)
+ }
+ }
+ } catch {
+ await MainActor.run {
+ AlertManager.shared.showAlertMsg(title: "Error decrypting file", message: "Error: \(error.localizedDescription)")
+ }
+ }
+ }
+ } else {
+ let url = getAppFilePath(fileSource.filePath)
+ showShareSheet(items: [url])
+ }
+}
+
struct CIFileView_Previews: PreviewProvider {
static var previews: some View {
let sentFile: ChatItem = ChatItem(
chatDir: .directSnd,
- meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
+ meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
content: .sndMsgContent(msgContent: .file("")),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
index b13ee52829..bb43179577 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift
@@ -16,6 +16,7 @@ struct CIImageView: View {
let maxWidth: CGFloat
@Binding var imgWidth: CGFloat?
@State var scrollProxy: ScrollViewProxy?
+ @State var metaColor: Color
@State private var showFullScreenImage = false
var body: some View {
@@ -36,9 +37,8 @@ struct CIImageView: View {
case .rcvInvitation:
Task {
if let user = ChatModel.shared.currentUser {
- await receiveFile(user: user, fileId: file.fileId)
+ await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile)
}
- // TODO image accepted alert?
}
case .rcvAccepted:
switch file.fileProtocol {
@@ -110,7 +110,7 @@ struct CIImageView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
- .foregroundColor(.white)
+ .foregroundColor(metaColor)
.padding(padding)
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
new file mode 100644
index 0000000000..a204d83f1e
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift
@@ -0,0 +1,79 @@
+//
+// CIMemberCreatedContactView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 19.09.2023.
+// Copyright © 2023 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import SimpleXChat
+
+struct CIMemberCreatedContactView: View {
+ var chatItem: ChatItem
+
+ var body: some View {
+ HStack(alignment: .bottom, spacing: 0) {
+ switch chatItem.chatDir {
+ case let .groupRcv(groupMember):
+ if let contactId = groupMember.memberContactId {
+ memberCreatedContactView(openText: "Open")
+ .onTapGesture {
+ dismissAllSheets(animated: true)
+ DispatchQueue.main.async {
+ ChatModel.shared.chatId = "@\(contactId)"
+ }
+ }
+ } else {
+ memberCreatedContactView()
+ }
+ default:
+ EmptyView()
+ }
+ }
+ .padding(.leading, 6)
+ .padding(.bottom, 6)
+ .textSelection(.disabled)
+ }
+
+ private func memberCreatedContactView(openText: LocalizedStringKey? = nil) -> some View {
+ var r = eventText()
+ if let openText {
+ r = r
+ + Text(openText)
+ .fontWeight(.medium)
+ .foregroundColor(.accentColor)
+ + Text(" ")
+ }
+ r = r + chatItem.timestampText
+ .fontWeight(.light)
+ .foregroundColor(.secondary)
+ return r.font(.caption)
+ }
+
+ private func eventText() -> Text {
+ if let member = chatItem.memberDisplayName {
+ return Text(member + " " + chatItem.content.text + " ")
+ .fontWeight(.light)
+ .foregroundColor(.secondary)
+ } else {
+ return Text(chatItem.content.text + " ")
+ .fontWeight(.light)
+ .foregroundColor(.secondary)
+ }
+ }
+}
+
+struct CIMemberCreatedContactView_Previews: PreviewProvider {
+ static var previews: some View {
+ let content = CIContent.rcvGroupEvent(rcvGroupEvent: .memberCreatedContact)
+ let chatItem = ChatItem(
+ chatDir: .groupRcv(groupMember: GroupMember.sampleData),
+ meta: CIMeta.getSample(1, .now, content.text, .rcvRead),
+ content: content,
+ quotedItem: nil,
+ file: nil
+ )
+ CIMemberCreatedContactView(chatItem: chatItem)
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
index de7b3e251e..30430dc19a 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
@@ -13,6 +13,7 @@ struct CIMetaView: View {
@EnvironmentObject var chat: Chat
var chatItem: ChatItem
var metaColor = Color.secondary
+ var paleMetaColor = Color(UIColor.tertiaryLabel)
var body: some View {
if chatItem.isDeletedContent {
@@ -20,16 +21,28 @@ struct CIMetaView: View {
} else {
let meta = chatItem.meta
let ttl = chat.chatInfo.timedMessagesTTL
+ let encrypted = chatItem.encryptedFile
switch meta.itemStatus {
- case .sndSent:
- ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .sent)
- case .sndRcvd:
- ZStack {
- ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd1)
- ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd2)
+ case let .sndSent(sndProgress):
+ switch sndProgress {
+ case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent)
+ case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent)
+ }
+ case let .sndRcvd(_, sndProgress):
+ switch sndProgress {
+ case .complete:
+ ZStack {
+ ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1)
+ ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2)
+ }
+ case .partial:
+ ZStack {
+ ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1)
+ ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2)
+ }
}
default:
- ciMetaText(meta, chatTTL: ttl, color: metaColor)
+ ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor)
}
}
}
@@ -41,7 +54,7 @@ enum SentCheckmark {
case rcvd2
}
-func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text {
+func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text {
var r = Text("")
if meta.itemEdited {
r = r + statusIconText("pencil", color)
@@ -61,14 +74,18 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparen
switch sent {
case nil: r = r + t1
case .sent: r = r + t1 + gap
- case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : color.opacity(0.67)) + 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(" ")
}
- return (r + meta.timestampText.foregroundColor(color)).font(.caption)
+ if let enc = encrypted {
+ r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
+ }
+ r = r + meta.timestampText.foregroundColor(color)
+ return r.font(.caption)
}
private func statusIconText(_ icon: String, _ color: Color) -> Text {
@@ -78,8 +95,12 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
struct CIMetaView_Previews: PreviewProvider {
static var previews: some View {
Group {
- CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
- CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, itemEdited: true))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
+ CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
}
.previewLayout(.fixed(width: 360, height: 100))
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
index f4f8a52eb2..e1a5c252ec 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift
@@ -16,7 +16,6 @@ struct CIRcvDecryptionError: View {
var msgDecryptError: MsgDecryptError
var msgCount: UInt32
var chatItem: ChatItem
- var showMember = false
@State private var alert: CIRcvDecryptionErrorAlert?
enum CIRcvDecryptionErrorAlert: Identifiable {
@@ -106,9 +105,6 @@ struct CIRcvDecryptionError: View {
ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 2) {
HStack {
- if showMember, let member = chatItem.memberDisplayName {
- Text(member).fontWeight(.medium) + Text(": ")
- }
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
@@ -122,7 +118,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(syncSupported ? .accentColor : .secondary)
.font(.callout)
+ Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, transparent: true)
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
)
}
.padding(.horizontal, 12)
@@ -137,20 +133,13 @@ struct CIRcvDecryptionError: View {
}
private func decryptionErrorItem(_ onClick: @escaping (() -> Void)) -> some View {
- func text() -> Text {
- Text(chatItem.content.text)
- .foregroundColor(.red)
- .italic()
- + Text(" ")
- + ciMetaText(chatItem.meta, chatTTL: nil, transparent: true)
- }
return ZStack(alignment: .bottomTrailing) {
HStack {
- if showMember, let member = chatItem.memberDisplayName {
- Text(member).fontWeight(.medium) + Text(": ") + text()
- } else {
- text()
- }
+ Text(chatItem.content.text)
+ .foregroundColor(.red)
+ .italic()
+ + Text(" ")
+ + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
}
.padding(.horizontal, 12)
CIMetaView(chatItem: chatItem)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
index 4387614918..3807a11b4e 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
@@ -22,6 +22,7 @@ struct CIVideoView: View {
@State private var scrollProxy: ScrollViewProxy?
@State private var preview: UIImage? = nil
@State private var player: AVPlayer?
+ @State private var fullPlayer: AVPlayer?
@State private var url: URL?
@State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil
@@ -36,6 +37,7 @@ struct CIVideoView: View {
self.scrollProxy = scrollProxy
if let url = getLoadedVideo(chatItem.file) {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false))
+ self._fullPlayer = State(initialValue: AVPlayer(url: url))
self._url = State(initialValue: url)
}
if let data = Data(base64Encoded: dropImagePrefix(image)),
@@ -57,7 +59,7 @@ struct CIVideoView: View {
if let file = file {
switch file.fileStatus {
case .rcvInvitation:
- receiveFileIfValidSize(file: file, receiveFile: receiveFile)
+ receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
case .rcvAccepted:
switch file.fileProtocol {
case .xftp:
@@ -83,7 +85,7 @@ struct CIVideoView: View {
}
if let file = file, case .rcvInvitation = file.fileStatus {
Button {
- receiveFileIfValidSize(file: file, receiveFile: receiveFile)
+ receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
}
@@ -96,6 +98,7 @@ struct CIVideoView: View {
DispatchQueue.main.async { videoWidth = w }
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
+ let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete
VideoPlayerView(player: player, url: url, showControls: false)
.frame(width: w, height: w * preview.size.height / preview.size.width)
.onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in
@@ -113,7 +116,9 @@ struct CIVideoView: View {
player.pause()
videoPlaying = false
case .paused:
- showFullScreenPlayer = true
+ if canBePlayed {
+ showFullScreenPlayer = true
+ }
default: ()
}
}
@@ -122,8 +127,9 @@ struct CIVideoView: View {
ChatModel.shared.stopPreviousRecPlay = url
player.play()
} label: {
- playPauseIcon("play.fill")
+ playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
+ .disabled(!canBePlayed)
}
}
loadingIndicator()
@@ -247,10 +253,11 @@ struct CIVideoView: View {
.padding([.trailing, .top], 11)
}
- private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64) async -> Void) {
+ // TODO encrypt: where file size is checked?
+ private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
Task {
if let user = ChatModel.shared.currentUser {
- await receiveFile(user, file.fileId)
+ await receiveFile(user, file.fileId, encrypted, false)
}
}
}
@@ -258,8 +265,7 @@ struct CIVideoView: View {
private func fullScreenPlayer(_ url: URL) -> some View {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
- VideoPlayer(player: createFullScreenPlayerAndPlay(url)) {
- }
+ VideoPlayer(player: fullPlayer)
.overlay(alignment: .topLeading, content: {
Button(action: { showFullScreenPlayer = false },
label: {
@@ -282,28 +288,29 @@ struct CIVideoView: View {
}
}
)
+ .onAppear {
+ DispatchQueue.main.asyncAfter(deadline: .now()) {
+ ChatModel.shared.stopPreviousRecPlay = url
+ if let player = fullPlayer {
+ player.play()
+ fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
+ player.seek(to: CMTime.zero)
+ player.play()
+ }
+ }
+ }
+ }
.onDisappear {
if let fullScreenTimeObserver = fullScreenTimeObserver {
NotificationCenter.default.removeObserver(fullScreenTimeObserver)
}
fullScreenTimeObserver = nil
+ fullPlayer?.pause()
+ fullPlayer?.seek(to: CMTime.zero)
}
}
}
- private func createFullScreenPlayerAndPlay(_ url: URL) -> AVPlayer {
- let player = AVPlayer(url: url)
- DispatchQueue.main.asyncAfter(deadline: .now()) {
- ChatModel.shared.stopPreviousRecPlay = url
- player.play()
- fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
- player.seek(to: CMTime.zero)
- player.play()
- }
- }
- return player
- }
-
private func addObserver(_ player: AVPlayer, _ url: URL) {
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
if let item = player.currentItem {
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
index 9541ac9483..b0875abe8d 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift
@@ -159,7 +159,8 @@ struct VoiceMessagePlayer: View {
}
}
.onChange(of: chatModel.stopPreviousRecPlay) { it in
- if let recordingFileName = getLoadedFileName(recordingFile), chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
+ if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
+ chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
@@ -174,8 +175,8 @@ struct VoiceMessagePlayer: View {
switch playbackState {
case .noPlayback:
Button {
- if let recordingFileName = getLoadedFileName(recordingFile) {
- startPlayback(recordingFileName)
+ if let recordingSource = getLoadedFileSource(recordingFile) {
+ startPlayback(recordingSource)
}
} label: {
playPauseIcon("play.fill")
@@ -219,7 +220,7 @@ struct VoiceMessagePlayer: View {
Button {
Task {
if let user = ChatModel.shared.currentUser {
- await receiveFile(user: user, fileId: recordingFile.fileId)
+ await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get())
}
}
} label: {
@@ -251,8 +252,8 @@ struct VoiceMessagePlayer: View {
.clipShape(Circle())
}
- private func startPlayback(_ recordingFileName: String) {
- chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
+ private func startPlayback(_ recordingSource: CryptoFile) {
+ chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 },
onFinishPlayback: {
@@ -260,7 +261,7 @@ struct VoiceMessagePlayer: View {
playbackTime = TimeInterval(0)
}
)
- audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
+ audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
playbackState = .playing
}
}
@@ -269,7 +270,7 @@ struct CIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
- meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
+ meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift
index 80bb7ea1ef..af4df40978 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift
@@ -12,13 +12,9 @@ import SimpleXChat
struct DeletedItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
- var showMember = false
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
- if showMember, let member = chatItem.memberDisplayName {
- Text(member).fontWeight(.medium) + Text(": ")
- }
Text(chatItem.content.text)
.foregroundColor(.secondary)
.italic()
@@ -37,10 +33,7 @@ struct DeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
DeletedItemView(chatItem: ChatItem.getDeletedContentSample())
- DeletedItemView(
- chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)),
- showMember: true
- )
+ DeletedItemView(chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)))
}
.previewLayout(.fixed(width: 360, height: 200))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift
index e45b5bd183..f5ae761e84 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift
@@ -32,7 +32,7 @@ func emojiText(_ text: String) -> Text {
struct EmojiItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
- EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent))
+ EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
}
.previewLayout(.fixed(width: 360, height: 70))
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
index 4446131a75..3f7ca3f836 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift
@@ -75,14 +75,14 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
static var previews: some View {
let sentVoiceMessage: ChatItem = ChatItem(
chatDir: .directSnd,
- meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
+ meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)),
quotedItem: nil,
file: CIFile.getSample(fileStatus: .sndComplete)
)
let voiceMessageWithQuote: ChatItem = ChatItem(
chatDir: .directSnd,
- meta: CIMeta.getSample(1, .now, "", .sndSent, itemEdited: true),
+ meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true),
content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)),
quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv),
file: CIFile.getSample(fileStatus: .sndComplete)
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
index 9888ae7e8e..aab0cd5f55 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift
@@ -18,7 +18,6 @@ struct FramedItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatInfo: ChatInfo
var chatItem: ChatItem
- var showMember = false
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@State var msgWidth: CGFloat = 0
@@ -57,7 +56,7 @@ struct FramedItemView: View {
}
}
- ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: framedMsgContentView)
+ ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: framedMsgContentView)
.padding(chatItem.content.msgContent != nil ? 0 : 4)
.overlay(DetermineWidth())
}
@@ -68,6 +67,7 @@ struct FramedItemView: View {
.padding(.horizontal, 12)
.padding(.bottom, 6)
.overlay(DetermineWidth())
+ .accessibilityLabel("")
}
}
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme))
@@ -97,7 +97,7 @@ struct FramedItemView: View {
} else {
switch (chatItem.content.msgContent) {
case let .image(text, image):
- CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy)
+ CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy, metaColor: metaColor)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
@@ -107,7 +107,7 @@ struct FramedItemView: View {
value: .white
)
} else {
- ciMsgContentView (chatItem, showMember)
+ ciMsgContentView(chatItem)
}
case let .video(text, image, duration):
CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy)
@@ -120,27 +120,27 @@ struct FramedItemView: View {
value: .white
)
} else {
- ciMsgContentView (chatItem, showMember)
+ ciMsgContentView(chatItem)
}
case let .voice(text, duration):
FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.overlay(DetermineWidth())
if text != "" {
- ciMsgContentView (chatItem, showMember)
+ ciMsgContentView(chatItem)
}
case let .file(text):
ciFileView(chatItem, text)
case let .link(_, preview):
CILinkView(linkPreview: preview)
- ciMsgContentView (chatItem, showMember)
+ ciMsgContentView(chatItem)
case let .unknown(_, text: text):
if chatItem.file == nil {
- ciMsgContentView (chatItem, showMember)
+ ciMsgContentView(chatItem)
} else {
ciFileView(chatItem, text)
}
default:
- ciMsgContentView (chatItem, showMember)
+ ciMsgContentView(chatItem)
}
}
}
@@ -232,17 +232,27 @@ struct FramedItemView: View {
}
private func ciQuotedMsgView(_ qi: CIQuote) -> some View {
- MsgContentView(
- text: qi.text,
- formattedText: qi.formattedText,
- sender: qi.getSender(membership())
- )
- .lineLimit(3)
- .font(.subheadline)
- .padding(.vertical, 6)
+ Group {
+ if let sender = qi.getSender(membership()) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(sender).font(.caption).foregroundColor(.secondary)
+ ciQuotedMsgTextView(qi, lines: 2)
+ }
+ } else {
+ ciQuotedMsgTextView(qi, lines: 3)
+ }
+ }
+ .padding(.top, 6)
.padding(.horizontal, 12)
}
+ private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View {
+ MsgContentView(text: qi.text, formattedText: qi.formattedText)
+ .lineLimit(lines)
+ .font(.subheadline)
+ .padding(.bottom, 6)
+ }
+
private func ciQuoteIconView(_ image: String) -> some View {
Image(systemName: image)
.resizable()
@@ -260,13 +270,12 @@ struct FramedItemView: View {
}
}
- @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View {
+ @ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View {
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
let rtl = isRightToLeft(text)
let v = MsgContentView(
text: text,
formattedText: text == "" ? [] : ci.formattedText,
- sender: showMember ? ci.memberDisplayName : nil,
meta: ci.meta,
rightToLeft: rtl
)
@@ -288,7 +297,7 @@ struct FramedItemView: View {
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
.overlay(DetermineWidth())
if text != "" || ci.meta.isLive {
- ciMsgContentView (chatItem, showMember)
+ ciMsgContentView (chatItem)
}
}
@@ -349,8 +358,8 @@ struct FramedItemView_Previews: PreviewProvider {
Group{
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
@@ -363,10 +372,10 @@ struct FramedItemView_Previews: PreviewProvider {
struct FramedItemView_Edited_Previews: PreviewProvider {
static var previews: some View {
Group {
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
@@ -381,10 +390,10 @@ struct FramedItemView_Edited_Previews: PreviewProvider {
struct FramedItemView_Deleted_Previews: PreviewProvider {
static var previews: some View {
Group {
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
- FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
+ FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
index 1ab631232e..9908d4d104 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift
@@ -12,10 +12,9 @@ import SimpleXChat
struct IntegrityErrorItemView: View {
var msgError: MsgErrorType
var chatItem: ChatItem
- var showMember = false
var body: some View {
- CIMsgError(chatItem: chatItem, showMember: showMember) {
+ CIMsgError(chatItem: chatItem) {
switch msgError {
case .msgSkipped:
AlertManager.shared.showAlertMsg(
@@ -54,14 +53,10 @@ struct IntegrityErrorItemView: View {
struct CIMsgError: View {
var chatItem: ChatItem
- var showMember = false
var onTap: () -> Void
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
- if showMember, let member = chatItem.memberDisplayName {
- Text(member).fontWeight(.medium) + Text(": ")
- }
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
index 1442f1a2a3..8e042a8c76 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift
@@ -12,13 +12,9 @@ import SimpleXChat
struct MarkedDeletedItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
- var showMember = false
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
- if showMember, let member = chatItem.memberDisplayName {
- Text(member).font(.caption).fontWeight(.medium) + Text(": ").font(.caption)
- }
if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted {
markedDeletedText("moderated by \(byGroupMember.chatViewName)")
} else {
@@ -46,7 +42,7 @@ struct MarkedDeletedItemView: View {
struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
- MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)))
+ MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
}
.previewLayout(.fixed(width: 360, height: 200))
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
index 3ac908bb78..498b3cb2e0 100644
--- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift
@@ -80,7 +80,7 @@ struct MsgContentView: View {
}
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
- (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, transparent: true)
+ (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true)
}
}
diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
index 4db32bc74f..61802c61fe 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
@@ -14,11 +14,23 @@ struct ChatItemInfoView: View {
var ci: ChatItem
@Binding var chatItemInfo: ChatItemInfo?
@State private var selection: CIInfoTab = .history
+ @State private var alert: CIInfoViewAlert? = nil
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum CIInfoTab {
case history
case quote
+ case delivery
+ }
+
+ enum CIInfoViewAlert: Identifiable {
+ case alert(title: String, text: String)
+
+ var id: String {
+ switch self {
+ case let .alert(title, text): return "alert \(title) \(text)"
+ }
+ }
}
var body: some View {
@@ -31,6 +43,11 @@ struct ChatItemInfoView: View {
}
}
}
+ .alert(item: $alert) { a in
+ switch(a) {
+ case let .alert(title, text): return Alert(title: Text(title), message: Text(text))
+ }
+ }
}
}
@@ -40,19 +57,44 @@ struct ChatItemInfoView: View {
: NSLocalizedString("Received message", comment: "message info title")
}
+ private var numTabs: Int {
+ var numTabs = 1
+ if chatItemInfo?.memberDeliveryStatuses != nil {
+ numTabs += 1
+ }
+ if ci.quotedItem != nil {
+ numTabs += 1
+ }
+ return numTabs
+ }
+
@ViewBuilder private func itemInfoView() -> some View {
- if let qi = ci.quotedItem {
+ if numTabs > 1 {
TabView(selection: $selection) {
+ if let mdss = chatItemInfo?.memberDeliveryStatuses {
+ deliveryTab(mdss)
+ .tabItem {
+ Label("Delivery", systemImage: "checkmark.message")
+ }
+ .tag(CIInfoTab.delivery)
+ }
historyTab()
.tabItem {
Label("History", systemImage: "clock")
}
.tag(CIInfoTab.history)
- quoteTab(qi)
- .tabItem {
- Label("In reply to", systemImage: "arrowshape.turn.up.left")
- }
- .tag(CIInfoTab.quote)
+ if let qi = ci.quotedItem {
+ quoteTab(qi)
+ .tabItem {
+ Label("In reply to", systemImage: "arrowshape.turn.up.left")
+ }
+ .tag(CIInfoTab.quote)
+ }
+ }
+ .onAppear {
+ if chatItemInfo?.memberDeliveryStatuses != nil {
+ selection = .delivery
+ }
}
} else {
historyTab()
@@ -217,9 +259,87 @@ struct ChatItemInfoView: View {
: Color(uiColor: .tertiarySystemGroupedBackground)
}
+ @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ details()
+ Divider().padding(.vertical)
+ Text("Delivery")
+ .font(.title2)
+ .padding(.bottom, 4)
+ memberDeliveryStatusesView(memberDeliveryStatuses)
+ }
+ .padding()
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+
+ @ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ let mss = membersStatuses(memberDeliveryStatuses)
+ if !mss.isEmpty {
+ ForEach(mss, id: \.0.groupMemberId) { memberStatus in
+ memberDeliveryStatusView(memberStatus.0, memberStatus.1)
+ }
+ } else {
+ Text("No delivery information")
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+
+ private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
+ memberDeliveryStatuses.compactMap({ mds in
+ if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) {
+ return (mem, mds.memberDeliveryStatus)
+ } else {
+ return nil
+ }
+ })
+ }
+
+ private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View {
+ HStack{
+ ProfileImage(imageStr: member.image)
+ .frame(width: 30, height: 30)
+ .padding(.trailing, 2)
+ Text(member.chatViewName)
+ .lineLimit(1)
+ Spacer()
+ let v = Group {
+ if let (icon, statusColor) = status.statusIcon(Color.secondary) {
+ switch status {
+ case .sndRcvd:
+ ZStack(alignment: .trailing) {
+ Image(systemName: icon)
+ .foregroundColor(statusColor.opacity(0.67))
+ .padding(.trailing, 6)
+ Image(systemName: icon)
+ .foregroundColor(statusColor.opacity(0.67))
+ }
+ default:
+ Image(systemName: icon)
+ .foregroundColor(statusColor)
+ }
+ } else {
+ Image(systemName: "ellipsis")
+ .foregroundColor(Color.secondary)
+ }
+ }
+
+ if let (title, text) = status.statusInfo {
+ v.onTapGesture {
+ alert = .alert(title: title, text: text)
+ }
+ } else {
+ v
+ }
+ }
+ }
+
private func itemInfoShareText() -> String {
let meta = ci.meta
- var shareText: [String] = [title, ""]
+ var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # "), title), ""]
shareText += [String.localizedStringWithFormat(NSLocalizedString("Sent at: %@", comment: "copied message info"), localTimestamp(meta.itemTs))]
if !ci.chatDir.sent {
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
@@ -245,7 +365,7 @@ struct ChatItemInfoView: View {
]
}
if let qi = ci.quotedItem {
- shareText += ["", NSLocalizedString("In reply to", comment: "copied message info")]
+ shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
let t = qi.text
shareText += [""]
if let sender = qi.getSender(nil) {
@@ -264,7 +384,7 @@ struct ChatItemInfoView: View {
}
if let chatItemInfo = chatItemInfo,
!chatItemInfo.itemVersions.isEmpty {
- shareText += ["", NSLocalizedString("History", comment: "copied message info")]
+ shareText += ["", NSLocalizedString("## History", comment: "copied message info")]
for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() {
let t = itemVersion.msgContent.text
shareText += [
diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift
index 245b55c921..a79047ebcc 100644
--- a/apps/ios/Shared/Views/Chat/ChatItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift
@@ -12,7 +12,6 @@ import SimpleXChat
struct ChatItemView: View {
var chatInfo: ChatInfo
var chatItem: ChatItem
- var showMember = false
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@Binding var revealed: Bool
@@ -23,7 +22,6 @@ struct ChatItemView: View {
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding, allowMenu: Binding = .constant(false), audioPlayer: Binding = .constant(nil), playbackState: Binding = .constant(.noPlayback), playbackTime: Binding = .constant(nil)) {
self.chatInfo = chatInfo
self.chatItem = chatItem
- self.showMember = showMember
self.maxWidth = maxWidth
_scrollProxy = .init(initialValue: scrollProxy)
_revealed = revealed
@@ -36,14 +34,14 @@ struct ChatItemView: View {
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && !revealed {
- MarkedDeletedItemView(chatItem: chatItem, showMember: showMember)
+ MarkedDeletedItemView(chatItem: chatItem)
} else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
- ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
+ ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
@@ -53,15 +51,16 @@ struct ChatItemView: View {
}
private func framedItemView() -> some View {
- FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
+ FramedItemView(chatInfo: chatInfo, chatItem: chatItem, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
}
}
struct ChatItemContentView: View {
+ @EnvironmentObject var chatModel: ChatModel
var chatInfo: ChatInfo
var chatItem: ChatItem
- var showMember: Bool
var msgContentView: () -> Content
+ @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
var body: some View {
switch chatItem.content {
@@ -71,10 +70,17 @@ struct ChatItemContentView: View {
case .rcvDeleted: deletedItemView()
case let .sndCall(status, duration): callItemView(status, duration)
case let .rcvCall(status, duration): callItemView(status, duration)
- case let .rcvIntegrityError(msgError): IntegrityErrorItemView(msgError: msgError, chatItem: chatItem, showMember: showMember)
- case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem, showMember: showMember)
+ case let .rcvIntegrityError(msgError):
+ if developerTools {
+ IntegrityErrorItemView(msgError: msgError, chatItem: chatItem)
+ } else {
+ ZStack {}
+ }
+ case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
+ case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText)
+ case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
case .rcvGroupEvent: eventItemView()
case .sndGroupEvent: eventItemView()
case .rcvConnEvent: eventItemView()
@@ -96,7 +102,7 @@ struct ChatItemContentView: View {
}
private func deletedItemView() -> some View {
- DeletedItemView(chatItem: chatItem, showMember: showMember)
+ DeletedItemView(chatItem: chatItem)
}
private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View {
@@ -108,12 +114,54 @@ struct ChatItemContentView: View {
}
private func eventItemView() -> some View {
- CIEventView(chatItem: chatItem)
+ return CIEventView(eventText: eventItemViewText())
+ }
+
+ private func eventItemViewText() -> Text {
+ if let member = chatItem.memberDisplayName {
+ return Text(member + " ")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .fontWeight(.light)
+ + chatEventText(chatItem)
+ } else {
+ return chatEventText(chatItem)
+ }
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
}
+
+ private var membersConnectedItemText: Text {
+ if let t = membersConnectedText {
+ return chatEventText(t, chatItem.timestampText)
+ } else {
+ return eventItemViewText()
+ }
+ }
+
+ private var membersConnectedText: LocalizedStringKey? {
+ let ns = chatModel.getConnectedMemberNames(chatItem)
+ return ns.count > 3
+ ? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected"
+ : ns.count == 3
+ ? "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected"
+ : ns.count == 2
+ ? "\(ns[0]) and \(ns[1]) connected"
+ : nil
+ }
+}
+
+func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
+ (Text(eventText) + Text(" ") + ts)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .fontWeight(.light)
+}
+
+func chatEventText(_ ci: ChatItem) -> Text {
+ chatEventText("\(ci.content.text)", ci.timestampText)
}
struct ChatItemView_Previews: PreviewProvider {
@@ -125,9 +173,9 @@ struct ChatItemView_Previews: PreviewProvider {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
- ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
- ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent, itemLive: true), revealed: Binding.constant(true))
- ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemLive: true), revealed: Binding.constant(true))
+ ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
+ ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
+ ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift
index bd57841a79..81a063dcfc 100644
--- a/apps/ios/Shared/Views/Chat/ChatView.swift
+++ b/apps/ios/Shared/Views/Chat/ChatView.swift
@@ -64,6 +64,7 @@ struct ChatView: View {
Spacer(minLength: 0)
+ connectingText()
ComposeView(
chat: chat,
composeState: $composeState,
@@ -149,6 +150,7 @@ struct ChatView: View {
HStack {
if contact.allowsFeature(.calls) {
callButton(contact, .audio, imageName: "phone")
+ .disabled(!contact.ready)
}
Menu {
if contact.allowsFeature(.calls) {
@@ -157,9 +159,11 @@ struct ChatView: View {
} label: {
Label("Video call", systemImage: "video")
}
+ .disabled(!contact.ready)
}
searchButton()
toggleNtfsButton(chat)
+ .disabled(!contact.ready)
} label: {
Image(systemName: "ellipsis")
}
@@ -261,7 +265,7 @@ struct ChatView: View {
return GeometryReader { g in
ScrollViewReader { proxy in
ScrollView {
- LazyVStack(spacing: 5) {
+ LazyVStack(spacing: 0) {
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
let voiceNoFrame = voiceWithoutFrame(ci)
let maxWidth = cInfo.chatType == .group
@@ -313,6 +317,19 @@ struct ChatView: View {
}
.scaleEffect(x: 1, y: -1, anchor: .center)
}
+
+ @ViewBuilder private func connectingText() -> some View {
+ if case let .direct(contact) = chat.chatInfo,
+ !contact.ready,
+ !contact.nextSendGrpInv {
+ Text("connecting…")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .padding(.top)
+ } else {
+ EmptyView()
+ }
+ }
private func floatingButtons(_ proxy: ScrollViewProxy) -> some View {
let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView)
@@ -430,68 +447,77 @@ struct ChatView: View {
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
- let prevItem = chatModel.getPrevChatItem(ci)
- HStack(alignment: .top, spacing: 0) {
- let showMember = prevItem == nil || showMemberImage(member, prevItem)
- if showMember {
- ProfileImage(imageStr: member.memberProfile.image)
- .frame(width: memberImageSize, height: memberImageSize)
- .onTapGesture { selectedMember = member }
- .appSheet(item: $selectedMember) { member in
- GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
+ let (prevItem, nextItem) = chatModel.getChatItemNeighbors(ci)
+ if ci.memberConnected != nil && nextItem?.memberConnected != nil {
+ // memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView
+ ZStack {} // scroll doesn't work if it's EmptyView()
+ } else {
+ if prevItem == nil || showMemberImage(member, prevItem) {
+ VStack(alignment: .leading, spacing: 4) {
+ if ci.content.showMemberName {
+ Text(member.displayName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.leading, memberImageSize + 14)
+ .padding(.top, 7)
}
+ HStack(alignment: .top, spacing: 8) {
+ ProfileImage(imageStr: member.memberProfile.image)
+ .frame(width: memberImageSize, height: memberImageSize)
+ .onTapGesture { selectedMember = member }
+ .appSheet(item: $selectedMember) { member in
+ GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
+ }
+ chatItemWithMenu(ci, maxWidth)
+ }
+ }
+ .padding(.top, 5)
+ .padding(.trailing)
+ .padding(.leading, 12)
} else {
- Rectangle().fill(.clear)
- .frame(width: memberImageSize, height: memberImageSize)
+ chatItemWithMenu(ci, maxWidth)
+ .padding(.top, 5)
+ .padding(.trailing)
+ .padding(.leading, memberImageSize + 8 + 12)
}
- ChatItemWithMenu(
- ci: ci,
- showMember: showMember,
- maxWidth: maxWidth,
- scrollProxy: scrollProxy,
- deleteMessage: deleteMessage,
- deletingItem: $deletingItem,
- composeState: $composeState,
- showDeleteMessage: $showDeleteMessage
- )
- .padding(.leading, 8)
- .environmentObject(chat)
}
- .padding(.trailing)
- .padding(.leading, 12)
} else {
- ChatItemWithMenu(
- ci: ci,
- maxWidth: maxWidth,
- scrollProxy: scrollProxy,
- deleteMessage: deleteMessage,
- deletingItem: $deletingItem,
- composeState: $composeState,
- showDeleteMessage: $showDeleteMessage
- )
- .padding(.horizontal)
- .environmentObject(chat)
+ chatItemWithMenu(ci, maxWidth)
+ .padding(.horizontal)
+ .padding(.top, 5)
}
}
-
+
+ private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
+ ChatItemWithMenu(
+ ci: ci,
+ maxWidth: maxWidth,
+ scrollProxy: scrollProxy,
+ deleteMessage: deleteMessage,
+ deletingItem: $deletingItem,
+ composeState: $composeState,
+ showDeleteMessage: $showDeleteMessage
+ )
+ .environmentObject(chat)
+ }
+
private struct ChatItemWithMenu: View {
@EnvironmentObject var chat: Chat
@Environment(\.colorScheme) var colorScheme
var ci: ChatItem
- var showMember: Bool = false
var maxWidth: CGFloat
var scrollProxy: ScrollViewProxy?
var deleteMessage: (CIDeleteMode) -> Void
@Binding var deletingItem: ChatItem?
@Binding var composeState: ComposeState
@Binding var showDeleteMessage: Bool
-
+
@State private var revealed = false
@State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo?
-
+
@State private var allowMenu: Bool = true
-
+
@State private var audioPlayer: AudioPlayer?
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
@State private var playbackTime: TimeInterval?
@@ -504,8 +530,9 @@ struct ChatView: View {
)
VStack(alignment: alignment.horizontal, spacing: 3) {
- ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
+ ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
+ .accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
chatItemReactions()
.padding(.bottom, 4)
@@ -591,15 +618,15 @@ struct ChatView: View {
}
menu.append(shareUIAction())
menu.append(copyUIAction())
- if let filePath = getLoadedFilePath(ci.file) {
+ if let fileSource = getLoadedFileSource(ci.file) {
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
if image.imageData != nil {
- menu.append(saveFileAction(filePath))
+ menu.append(saveFileAction(fileSource))
} else {
menu.append(saveImageAction(image))
}
} else {
- menu.append(saveFileAction(filePath))
+ menu.append(saveFileAction(fileSource))
}
}
if ci.meta.editable && !mc.isVoice && !live {
@@ -737,13 +764,12 @@ struct ChatView: View {
}
}
- private func saveFileAction(_ filePath: String) -> UIAction {
+ private func saveFileAction(_ fileSource: CryptoFile) -> UIAction {
UIAction(
title: NSLocalizedString("Save", comment: "chat item action"),
- image: UIImage(systemName: "square.and.arrow.down")
+ image: UIImage(systemName: fileSource.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open")
) { _ in
- let fileURL = URL(fileURLWithPath: filePath)
- showShareSheet(items: [fileURL])
+ saveCryptoFile(fileSource)
}
}
@@ -770,6 +796,12 @@ struct ChatView: View {
await MainActor.run {
chatItemInfo = ciInfo
}
+ if case let .group(gInfo) = chat.chatInfo {
+ let groupMembers = await apiListMembers(gInfo.groupId)
+ await MainActor.run {
+ ChatModel.shared.groupMembers = groupMembers
+ }
+ }
} catch let error {
logger.error("apiGetChatItemInfo error: \(responseError(error))")
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
index 1d47227094..3328da8dbe 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift
@@ -44,7 +44,6 @@ struct ComposeState {
var contextItem: ComposeContextItem
var voiceMessageRecordingState: VoiceMessageRecordingState
var inProgress = false
- var disabled = false
var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
init(
@@ -168,25 +167,23 @@ struct ComposeState {
}
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
- let chatItemPreview: ComposePreview
switch chatItem.content.msgContent {
case .text:
- chatItemPreview = .noPreview
+ return .noPreview
case let .link(_, preview: preview):
- chatItemPreview = .linkPreview(linkPreview: preview)
+ return .linkPreview(linkPreview: preview)
case let .image(_, image):
- chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
+ return .mediaPreviews(mediaPreviews: [(image, nil)])
case let .video(_, image, _):
- chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
+ return .mediaPreviews(mediaPreviews: [(image, nil)])
case let .voice(_, duration):
- chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
+ return .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
case .file:
let fileName = chatItem.file?.fileName ?? ""
- chatItemPreview = .filePreview(fileName: fileName, file: getAppFilePath(fileName))
+ return .filePreview(fileName: fileName, file: getAppFilePath(fileName))
default:
- chatItemPreview = .noPreview
+ return .noPreview
}
- return chatItemPreview
}
enum UploadContent: Equatable {
@@ -241,6 +238,7 @@ struct ComposeView: View {
@State var pendingLinkUrl: URL? = nil
@State var cancelledLinks: Set = []
+ @Environment(\.colorScheme) private var colorScheme
@State private var showChooseSource = false
@State private var showMediaPicker = false
@State private var showTakePhoto = false
@@ -255,8 +253,13 @@ struct ComposeView: View {
// this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false
+ @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
+
var body: some View {
VStack(spacing: 0) {
+ if chat.chatInfo.contact?.nextSendGrpInv ?? false {
+ ContextInvitingContactMemberView()
+ }
contextItemView()
switch (composeState.editing, composeState.preview) {
case (true, .filePreview): EmptyView()
@@ -270,7 +273,7 @@ struct ComposeView: View {
Image(systemName: "paperclip")
.resizable()
}
- .disabled(composeState.attachmentDisabled || !chat.userCanSend)
+ .disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
.frame(width: 25, height: 25)
.padding(.bottom, 12)
.padding(.leading, 12)
@@ -298,6 +301,7 @@ struct ComposeView: View {
composeState.liveMessage = nil
chatModel.removeLiveDummy()
},
+ nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
startVoiceMessageRecording: {
@@ -309,7 +313,10 @@ struct ComposeView: View {
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
- keyboardVisible: $keyboardVisible
+ keyboardVisible: $keyboardVisible,
+ sendButtonColor: chat.chatInfo.incognito
+ ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
+ : .accentColor
)
.padding(.trailing, 12)
.background(.background)
@@ -442,7 +449,15 @@ struct ComposeView: View {
} else if (composeState.inProgress) {
clearCurrentDraft()
} else if !composeState.empty {
- saveCurrentDraft()
+ if case .recording = composeState.voiceMessageRecordingState {
+ finishVoiceMessageRecording()
+ if let fileName = composeState.voiceMessageRecordingFileName {
+ chatModel.filesToDelete.insert(getAppFilePath(fileName))
+ }
+ }
+ if saveLastDraft {
+ saveCurrentDraft()
+ }
} else {
cancelCurrentVoiceRecording()
clearCurrentDraft()
@@ -606,7 +621,9 @@ struct ComposeView: View {
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
await sending()
}
- if case let .editingItem(ci) = composeState.contextItem {
+ if chat.chatInfo.contact?.nextSendGrpInv ?? false {
+ await sendMemberContactInvitation()
+ } else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live)
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
sent = await updateMessage(liveMessage.chatItem, live: live)
@@ -643,10 +660,10 @@ struct ComposeView: View {
}
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
- chatModel.filesToDelete.remove(getAppFilePath(recordingFileName))
- sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName, ttl: ttl)
+ let file = voiceCryptoFile(recordingFileName)
+ sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
case let .filePreview(_, file):
- if let savedFile = saveFileFromURL(file) {
+ if let savedFile = saveFileFromURL(file, encrypted: privacyEncryptLocalFilesGroupDefault.get()) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
}
@@ -655,9 +672,19 @@ struct ComposeView: View {
return sent
func sending() async {
- await MainActor.run { composeState.disabled = true }
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
- if composeState.disabled { composeState.inProgress = true }
+ await MainActor.run { composeState.inProgress = true }
+ }
+
+ func sendMemberContactInvitation() async {
+ do {
+ let mc = checkLinkPreview()
+ let contact = try await apiSendMemberContactInvitation(chat.chatInfo.apiId, mc)
+ await MainActor.run {
+ self.chatModel.updateContact(contact)
+ }
+ } catch {
+ logger.error("ChatView.sendMemberContactInvitation error: \(error.localizedDescription)")
+ AlertManager.shared.showAlertMsg(title: "Error sending member contact invitation", message: "Error: \(responseError(error))")
}
}
@@ -717,13 +744,28 @@ struct ComposeView: View {
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
- if case let .video(_, url, duration) = data, let savedFile = saveFileFromURLWithoutLoad(url) {
+ if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
}
- func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
+ func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
+ if !privacyEncryptLocalFilesGroupDefault.get() {
+ return CryptoFile.plain(fileName)
+ }
+ let url = getAppFilePath(fileName)
+ let toFile = generateNewFileName("voice", "m4a")
+ let toUrl = getAppFilePath(toFile)
+ if let cfArgs = try? encryptCryptoFile(fromPath: url.path, toPath: toUrl.path) {
+ removeFile(url)
+ return CryptoFile(filePath: toFile, cryptoArgs: cfArgs)
+ } else {
+ return nil
+ }
+ }
+
+ func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
if let chatItem = await apiSendMessage(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
@@ -740,7 +782,7 @@ struct ComposeView: View {
return chatItem
}
if let file = file {
- removeFile(file)
+ removeFile(file.filePath)
}
return nil
}
@@ -760,7 +802,7 @@ struct ComposeView: View {
}
}
- func saveAnyImage(_ img: UploadContent) -> String? {
+ func saveAnyImage(_ img: UploadContent) -> CryptoFile? {
switch img {
case let .simpleImage(image): return saveImage(image)
case let .animatedImage(image): return saveAnimImage(image)
@@ -852,7 +894,6 @@ struct ComposeView: View {
private func clearState(live: Bool = false) {
if live {
- composeState.disabled = false
composeState.inProgress = false
} else {
composeState = ComposeState()
@@ -865,12 +906,6 @@ struct ComposeView: View {
}
private func saveCurrentDraft() {
- if case .recording = composeState.voiceMessageRecordingState {
- finishVoiceMessageRecording()
- if let fileName = composeState.voiceMessageRecordingFileName {
- chatModel.filesToDelete.insert(getAppFilePath(fileName))
- }
- }
chatModel.draft = composeState
chatModel.draftChatId = chat.id
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift
index 2bd23f8ae7..2617bc77bc 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift
@@ -188,7 +188,7 @@ struct ComposeVoiceView: View {
playbackTime = recordingTime // animate progress bar to the end
}
)
- audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
+ audioPlayer?.start(fileSource: CryptoFile.plain(recordingFileName), at: playbackTime)
playbackState = .playing
}
}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift
new file mode 100644
index 0000000000..acb4f6d3e1
--- /dev/null
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextInvitingContactMemberView.swift
@@ -0,0 +1,32 @@
+//
+// ContextInvitingContactMemberView.swift
+// SimpleX (iOS)
+//
+// Created by spaced4ndy on 18.09.2023.
+// Copyright © 2023 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+
+struct ContextInvitingContactMemberView: View {
+ @Environment(\.colorScheme) var colorScheme
+
+ var body: some View {
+ HStack {
+ Image(systemName: "message")
+ .foregroundColor(.secondary)
+ Text("Send direct message to connect")
+ }
+ .padding(12)
+ .frame(minHeight: 50)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(colorScheme == .light ? sentColorLight : sentColorDark)
+ .padding(.top, 8)
+ }
+}
+
+struct ContextInvitingContactMemberView_Previews: PreviewProvider {
+ static var previews: some View {
+ ContextInvitingContactMemberView()
+ }
+}
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
index a9847afe4d..153d89fc27 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift
@@ -22,13 +22,14 @@ struct ContextItemView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundColor(.secondary)
- MsgContentView(
- text: contextItem.text,
- formattedText: contextItem.formattedText,
- sender: contextItem.memberDisplayName
- )
- .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
- .lineLimit(3)
+ if let sender = contextItem.memberDisplayName {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(sender).font(.caption).foregroundColor(.secondary)
+ msgContentView(lines: 2)
+ }
+ } else {
+ msgContentView(lines: 3)
+ }
Spacer()
Button {
withAnimation {
@@ -44,6 +45,15 @@ struct ContextItemView: View {
.background(chatItemFrameColor(contextItem, colorScheme))
.padding(.top, 8)
}
+
+ private func msgContentView(lines: Int) -> some View {
+ MsgContentView(
+ text: contextItem.text,
+ formattedText: contextItem.formattedText
+ )
+ .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
+ .lineLimit(lines)
+ }
}
struct ContextItemView_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
index f90784234c..6eed51788e 100644
--- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
+++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift
@@ -17,6 +17,7 @@ struct SendMessageView: View {
var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil
var cancelLiveMessage: (() -> Void)? = nil
+ var nextSendGrpInv: Bool = false
var showVoiceMessageButton: Bool = true
var voiceMessageAllowed: Bool = true
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
@@ -28,6 +29,7 @@ struct SendMessageView: View {
@State private var holdingVMR = false
@Namespace var namespace
@Binding var keyboardVisible: Bool
+ var sendButtonColor = Color.accentColor
@State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
@@ -36,6 +38,7 @@ struct SendMessageView: View {
@State private var showCustomDisappearingMessageDialogue = false
@State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
+ @State private var progressByTimeout = false
var maxHeight: CGFloat = 360
var minHeight: CGFloat = 37
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
@@ -81,7 +84,7 @@ struct SendMessageView: View {
}
}
- if composeState.inProgress {
+ if progressByTimeout {
ProgressView()
.scaleEffect(1.4)
.frame(width: 31, height: 31, alignment: .center)
@@ -102,12 +105,23 @@ struct SendMessageView: View {
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
.frame(height: teHeight)
}
+ .onChange(of: composeState.inProgress) { inProgress in
+ if inProgress {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+ progressByTimeout = composeState.inProgress
+ }
+ } else {
+ progressByTimeout = false
+ }
+ }
.padding(.vertical, 8)
}
@ViewBuilder private func composeActionButtons() -> some View {
let vmrs = composeState.voiceMessageRecordingState
- if showVoiceMessageButton
+ if nextSendGrpInv {
+ inviteMemberContactButton()
+ } else if showVoiceMessageButton
&& composeState.message.isEmpty
&& !composeState.editing
&& composeState.liveMessage == nil
@@ -119,7 +133,7 @@ struct SendMessageView: View {
startVoiceMessageRecording: startVoiceMessageRecording,
finishVoiceMessageRecording: finishVoiceMessageRecording,
holdingVMR: $holdingVMR,
- disabled: composeState.disabled
+ disabled: composeState.inProgress
)
} else {
voiceMessageNotAllowedButton()
@@ -151,6 +165,24 @@ struct SendMessageView: View {
.padding([.top, .trailing], 4)
}
+ private func inviteMemberContactButton() -> some View {
+ Button {
+ sendMessage(nil)
+ } label: {
+ Image(systemName: "arrow.up.circle.fill")
+ .resizable()
+ .foregroundColor(sendButtonColor)
+ .frame(width: sendButtonSize, height: sendButtonSize)
+ .opacity(sendButtonOpacity)
+ }
+ .disabled(
+ !composeState.sendEnabled ||
+ composeState.inProgress
+ )
+ .frame(width: 29, height: 29)
+ .padding([.bottom, .trailing], 4)
+ }
+
private func sendMessageButton() -> some View {
Button {
sendMessage(nil)
@@ -159,13 +191,13 @@ struct SendMessageView: View {
? "checkmark.circle.fill"
: "arrow.up.circle.fill")
.resizable()
- .foregroundColor(.accentColor)
+ .foregroundColor(sendButtonColor)
.frame(width: sendButtonSize, height: sendButtonSize)
.opacity(sendButtonOpacity)
}
.disabled(
!composeState.sendEnabled ||
- composeState.disabled ||
+ composeState.inProgress ||
(!voiceMessageAllowed && composeState.voicePreview) ||
composeState.endLiveDisabled
)
@@ -293,7 +325,7 @@ struct SendMessageView: View {
Image(systemName: "mic")
.foregroundColor(.secondary)
}
- .disabled(composeState.disabled)
+ .disabled(composeState.inProgress)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
@@ -378,7 +410,7 @@ struct SendMessageView: View {
Image(systemName: "stop.fill")
.foregroundColor(.accentColor)
}
- .disabled(composeState.disabled)
+ .disabled(composeState.inProgress)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index 87954def69..3b9ef347e8 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -9,6 +9,8 @@
import SwiftUI
import SimpleXChat
+let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
+
struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@@ -21,6 +23,8 @@ struct GroupChatInfoView: View {
@State private var showAddMembersSheet: Bool = false
@State private var connectionStats: ConnectionStats?
@State private var connectionCode: String?
+ @State private var sendReceipts = SendReceipts.userDefault(true)
+ @State private var sendReceiptsUserDefault = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var searchText: String = ""
@FocusState private var searchFocussed
@@ -30,6 +34,7 @@ struct GroupChatInfoView: View {
case clearChatAlert
case leaveGroupAlert
case cantInviteIncognitoAlert
+ case largeGroupReceiptsDisabled
var id: GroupChatInfoViewAlert { get { self } }
}
@@ -52,6 +57,11 @@ struct GroupChatInfoView: View {
addOrEditWelcomeMessage()
}
groupPreferencesButton($groupInfo)
+ if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
+ sendReceiptsOption()
+ } else {
+ sendReceiptsOptionDisabled()
+ }
} header: {
Text("")
} footer: {
@@ -115,9 +125,14 @@ struct GroupChatInfoView: View {
case .clearChatAlert: return clearChatAlert()
case .leaveGroupAlert: return leaveGroupAlert()
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
+ case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
}
}
.onAppear {
+ if let currentUser = chatModel.currentUser {
+ sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups
+ }
+ sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
do {
if let link = try apiGetGroupLink(groupInfo.groupId) {
(groupLink, groupLinkMemberRole) = link
@@ -284,9 +299,9 @@ struct GroupChatInfoView: View {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
await MainActor.run {
- chatModel.removeChat(chat.chatInfo.id)
- chatModel.chatId = nil
dismiss()
+ chatModel.chatId = nil
+ chatModel.removeChat(chat.chatInfo.id)
}
} catch let error {
logger.error("deleteGroupAlert apiDeleteChat error: \(error.localizedDescription)")
@@ -328,6 +343,38 @@ struct GroupChatInfoView: View {
secondaryButton: .cancel()
)
}
+
+ private func sendReceiptsOption() -> some View {
+ Picker(selection: $sendReceipts) {
+ ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
+ Text(opt.text)
+ }
+ } label: {
+ Label("Send receipts", systemImage: "checkmark.message")
+ }
+ .frame(height: 36)
+ .onChange(of: sendReceipts) { _ in
+ setSendReceipts()
+ }
+ }
+
+ private func setSendReceipts() {
+ var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
+ chatSettings.sendRcpts = sendReceipts.bool()
+ updateChatSettings(chat, chatSettings: chatSettings)
+ }
+
+ private func sendReceiptsOptionDisabled() -> some View {
+ HStack {
+ Label("Send receipts", systemImage: "checkmark.message")
+ Spacer()
+ Text("disabled")
+ .foregroundStyle(.secondary)
+ }
+ .onTapGesture {
+ alert = .largeGroupReceiptsDisabled
+ }
+ }
}
func groupPreferencesButton(_ groupInfo: Binding, _ creatingGroup: Bool = false) -> some View {
@@ -356,6 +403,13 @@ func cantInviteIncognitoAlert() -> Alert {
)
}
+func largeGroupReceiptsDisabledAlert() -> Alert {
+ Alert(
+ title: Text("Receipts are disabled"),
+ message: Text("This group has over \(SMALL_GROUPS_RCPS_MEM_LIMIT) members, delivery receipts are not sent.")
+ )
+}
+
struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData)
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
index 37e00863fe..5ec14f5be1 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift
@@ -19,8 +19,10 @@ struct GroupMemberInfoView: View {
@State private var connectionCode: String? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
+ @State private var connectToMemberDialog: Bool = false
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var justOpened = true
+ @State private var progressIndicator = false
enum GroupMemberInfoViewAlert: Identifiable {
case removeMemberAlert(mem: GroupMember)
@@ -38,8 +40,8 @@ struct GroupMemberInfoView: View {
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
- case .connRequestSentAlert: return "connRequestSentAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
+ case .connRequestSentAlert: return "connRequestSentAlert"
case let .error(title, _): return "error \(title)"
case let .other(alert): return "other \(alert)"
}
@@ -64,161 +66,173 @@ struct GroupMemberInfoView: View {
}
private func groupMemberInfoView() -> some View {
- VStack {
- List {
- groupMemberInfoHeader(member)
- .listRowBackground(Color.clear)
+ ZStack {
+ VStack {
+ List {
+ groupMemberInfoHeader(member)
+ .listRowBackground(Color.clear)
- if member.memberActive {
- Section {
- if let contactId = member.memberContactId {
- if let chat = knownDirectChat(contactId) {
+ if member.memberActive {
+ Section {
+ if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
knownDirectChatButton(chat)
} else if groupInfo.fullGroupPreferences.directMessages.on {
- newDirectChatButton(contactId)
+ if let contactId = member.memberContactId {
+ newDirectChatButton(contactId)
+ } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
+ createMemberContactButton()
+ }
}
+ if let code = connectionCode { verifyCodeButton(code) }
+ if let connStats = connectionStats,
+ connStats.ratchetSyncAllowed {
+ synchronizeConnectionButton()
+ }
+ // } else if developerTools {
+ // synchronizeConnectionButtonForce()
+ // }
}
- if let code = connectionCode { verifyCodeButton(code) }
- if let connStats = connectionStats,
- connStats.ratchetSyncAllowed {
- synchronizeConnectionButton()
- }
-// } else if developerTools {
-// synchronizeConnectionButtonForce()
-// }
}
- }
- if let contactLink = member.contactLink {
- Section {
- QRCode(uri: contactLink)
- Button {
- showShareSheet(items: [contactLink])
- } label: {
- Label("Share address", systemImage: "square.and.arrow.up")
- }
- if let contactId = member.memberContactId {
- if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
+ if let contactLink = member.contactLink {
+ Section {
+ QRCode(uri: contactLink)
+ Button {
+ showShareSheet(items: [contactLink])
+ } label: {
+ Label("Share address", systemImage: "square.and.arrow.up")
+ }
+ if let contactId = member.memberContactId {
+ if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
+ connectViaAddressButton(contactLink)
+ }
+ } else {
connectViaAddressButton(contactLink)
}
- } else {
- connectViaAddressButton(contactLink)
+ } header: {
+ Text("Address")
+ } footer: {
+ Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
}
- } 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)
+ 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)
+ if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
+ Picker("Change role", selection: $newRole) {
+ ForEach(roles) { role in
+ Text(role.text)
+ }
}
+ .frame(height: 36)
+ } else {
+ infoRow("Role", member.memberRole.text)
+ }
+
+ // TODO invited by - need to get contact by contact id
+ if let conn = member.activeConn {
+ let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
+ infoRow("Connection", connLevelDesc)
}
- .frame(height: 36)
- } else {
- infoRow("Role", member.memberRole.text)
}
- // TODO invited by - need to get contact by contact id
- if let conn = member.activeConn {
- let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
- infoRow("Connection", connLevelDesc)
- }
- }
-
- if let connStats = connectionStats {
- Section("Servers") {
- // TODO network connection status
- Button("Change receiving address") {
- alert = .switchAddressAlert
- }
- .disabled(
- connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
- || connStats.ratchetSyncSendProhibited
- )
- if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
- Button("Abort changing address") {
- alert = .abortSwitchAddressAlert
+ if let connStats = connectionStats {
+ Section("Servers") {
+ // TODO network connection status
+ Button("Change receiving address") {
+ alert = .switchAddressAlert
}
.disabled(
- connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
+ 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 })
}
- smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
- smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
}
- }
- if member.canBeRemoved(groupInfo: groupInfo) {
- Section {
- removeMemberButton(member)
+ if member.canBeRemoved(groupInfo: groupInfo) {
+ Section {
+ removeMemberButton(member)
+ }
}
- }
- if developerTools {
- Section("For console") {
- infoRow("Local name", member.localDisplayName)
- infoRow("Database ID", "\(member.groupMemberId)")
+ if developerTools {
+ Section("For console") {
+ infoRow("Local name", member.localDisplayName)
+ infoRow("Database ID", "\(member.groupMemberId)")
+ }
+ }
+ }
+ .navigationBarHidden(true)
+ .onAppear {
+ if #unavailable(iOS 16) {
+ // this condition prevents re-setting picker
+ if !justOpened { return }
+ }
+ newRole = member.memberRole
+ do {
+ let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
+ let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
+ member = mem
+ connectionStats = stats
+ connectionCode = code
+ } catch let error {
+ logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
+ }
+ justOpened = false
+ }
+ .onChange(of: newRole) { _ in
+ if newRole != member.memberRole {
+ alert = .changeMemberRoleAlert(mem: member, role: newRole)
}
}
}
- .navigationBarHidden(true)
- .onAppear {
- if #unavailable(iOS 16) {
- // this condition prevents re-setting picker
- if !justOpened { return }
- }
- newRole = member.memberRole
- do {
- let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
- let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
- member = mem
- connectionStats = stats
- connectionCode = code
- } catch let error {
- logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
- }
- justOpened = false
- }
- .onChange(of: newRole) { _ in
- if newRole != member.memberRole {
- alert = .changeMemberRoleAlert(mem: member, role: newRole)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .alert(item: $alert) { alertItem in
+ switch(alertItem) {
+ case let .removeMemberAlert(mem): return removeMemberAlert(mem)
+ case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
+ case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
+ case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
+ case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
+ case let .connRequestSentAlert(type): return connReqSentAlert(type)
+ case let .error(title, error): return Alert(title: Text(title), message: Text(error))
+ case let .other(alert): return alert
}
}
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
- .alert(item: $alert) { alertItem in
- switch(alertItem) {
- case let .removeMemberAlert(mem): return removeMemberAlert(mem)
- case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
- case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
- case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
- case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
- case let .connRequestSentAlert(type): return connReqSentAlert(type)
- case let .error(title, error): return Alert(title: Text(title), message: Text(error))
- case let .other(alert): return alert
+
+ if progressIndicator {
+ ProgressView().scaleEffect(2)
}
}
}
func connectViaAddressButton(_ contactLink: String) -> some View {
Button {
- connectViaAddress(contactLink)
+ connectToMemberDialog = true
} label: {
Label("Connect", systemImage: "link")
}
+ .confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) {
+ Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) }
+ Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) }
+ }
}
- func connectViaAddress(_ contactLink: String) {
+ func connectViaAddress(incognito: Bool, contactLink: String) {
Task {
- let (connReqType, connectAlert) = await apiConnect_(connReq: contactLink)
+ let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink)
if let connReqType = connReqType {
alert = .connRequestSentAlert(type: connReqType)
} else if let connectAlert = connectAlert {
@@ -255,6 +269,33 @@ struct GroupMemberInfoView: View {
}
}
+ func createMemberContactButton() -> some View {
+ Button {
+ progressIndicator = true
+ Task {
+ do {
+ let memberContact = try await apiCreateMemberContact(groupInfo.apiId, member.groupMemberId)
+ await MainActor.run {
+ progressIndicator = false
+ chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
+ dismissAllSheets(animated: true)
+ chatModel.chatId = memberContact.id
+ chatModel.setContactNetworkStatus(memberContact, .connected)
+ }
+ } catch let error {
+ logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
+ let a = getErrorAlert(error, "Error creating member contact")
+ await MainActor.run {
+ progressIndicator = false
+ alert = .error(title: a.title, error: a.message)
+ }
+ }
+ }
+ } label: {
+ Label("Send direct message", systemImage: "message")
+ }
+ }
+
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
VStack {
ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
index 32adf861f3..729556e739 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift
@@ -130,7 +130,7 @@ struct GroupProfileView: View {
let err = responseError(error)
saveGroupError = err
showSaveErrorAlert = true
- logger.error("UserProfile apiUpdateProfile error: \(err)")
+ logger.error("GroupProfile apiUpdateGroup error: \(err)")
}
}
}
diff --git a/apps/ios/Shared/Views/Chat/ScanCodeView.swift b/apps/ios/Shared/Views/Chat/ScanCodeView.swift
index d5b6edf906..09861fa50b 100644
--- a/apps/ios/Shared/Views/Chat/ScanCodeView.swift
+++ b/apps/ios/Shared/Views/Chat/ScanCodeView.swift
@@ -19,7 +19,7 @@ struct ScanCodeView: View {
VStack(alignment: .leading) {
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
- .border(.gray)
+ .cornerRadius(12)
Text("Scan security code from your contact's app.")
.padding(.top)
}
diff --git a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
index 21269f8435..75e31c26ed 100644
--- a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
+++ b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift
@@ -64,7 +64,7 @@ struct VerifyCodeView: View {
HStack {
NavigationLink {
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
- .navigationBarTitleDisplayMode(.inline)
+ .navigationBarTitleDisplayMode(.large)
.navigationTitle("Scan code")
} label: {
Label("Scan code", systemImage: "qrcode")
diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
index 2521c919a1..e7580530b6 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift
@@ -49,11 +49,10 @@ struct ChatListNavLink: View {
}
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
- let v = NavLinkPlain(
+ NavLinkPlain(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
- label: { ChatPreviewView(chat: chat) },
- disabled: !contact.ready
+ label: { ChatPreviewView(chat: chat) }
)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
@@ -76,14 +75,6 @@ struct ChatListNavLink: View {
.tint(.red)
}
.frame(height: rowHeights[dynamicTypeSize])
-
- if contact.ready {
- v
- } else {
- v.onTapGesture {
- AlertManager.shared.showAlert(pendingContactAlert(chat, contact))
- }
- }
}
@ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
@@ -222,9 +213,15 @@ struct ChatListNavLink: View {
ContactRequestView(contactRequest: contactRequest, chat: chat)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
- Task { await acceptContactRequest(contactRequest) }
- } label: { Label("Accept", systemImage: chatModel.incognito ? "theatermasks" : "checkmark") }
- .tint(chatModel.incognito ? .indigo : .accentColor)
+ Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
+ } label: { Label("Accept", systemImage: "checkmark") }
+ .tint(.accentColor)
+ Button {
+ Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
+ } label: {
+ Label("Accept incognito", systemImage: "theatermasks")
+ }
+ .tint(.indigo)
Button {
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
} label: {
@@ -234,9 +231,10 @@ struct ChatListNavLink: View {
}
.frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showContactRequestDialog = true }
- .confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
- Button(chatModel.incognito ? "Accept incognito" : "Accept contact") { Task { await acceptContactRequest(contactRequest) } }
- Button("Reject contact (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
+ .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
+ Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
+ Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
+ Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
}
}
diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift
index 03dd241087..eb0a5cba68 100644
--- a/apps/ios/Shared/Views/ChatList/ChatListView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift
@@ -60,8 +60,6 @@ struct ChatListView: View {
chatList
}
}
- .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
- .onAppear() { connectViaUrl() }
.onDisappear() { withAnimation { userPickerVisible = false } }
.refreshable {
AlertManager.shared.showAlert(Alert(
@@ -108,11 +106,6 @@ struct ChatListView: View {
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
- if (chatModel.incognito) {
- Image(systemName: "theatermasks")
- .foregroundColor(.indigo)
- .padding(.trailing, 8)
- }
Text("Chats")
.font(.headline)
if chatModel.chats.count > 0 {
diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
index 7f2493dc26..3ac8fada74 100644
--- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
+++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift
@@ -15,6 +15,8 @@ struct ChatPreviewView: View {
@Environment(\.colorScheme) var colorScheme
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
+ @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
+
var body: some View {
let cItem = chat.chatItems.last
return HStack(spacing: 8) {
@@ -41,11 +43,9 @@ struct ChatPreviewView: View {
ZStack(alignment: .topTrailing) {
chatMessagePreview(cItem)
- if case .direct = chat.chatInfo {
- chatStatusImage()
- .padding(.top, 24)
- .frame(maxWidth: .infinity, alignment: .trailing)
- }
+ chatStatusImage()
+ .padding(.top, 26)
+ .frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.trailing, 8)
@@ -59,12 +59,9 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
if case let .group(groupInfo) = chat.chatInfo {
switch (groupInfo.membership.memberStatus) {
- case .memLeft:
- groupInactiveIcon()
- case .memRemoved:
- groupInactiveIcon()
- case .memGroupDeleted:
- groupInactiveIcon()
+ case .memLeft: groupInactiveIcon()
+ case .memRemoved: groupInactiveIcon()
+ case .memGroupDeleted: groupInactiveIcon()
default: EmptyView()
}
} else {
@@ -74,7 +71,7 @@ struct ChatPreviewView: View {
@ViewBuilder private func groupInactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill")
- .foregroundColor(.secondary)
+ .foregroundColor(.secondary.opacity(0.65))
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
}
@@ -106,7 +103,7 @@ struct ChatPreviewView: View {
.kerning(-2)
}
- private func chatPreviewLayout(_ text: Text) -> some View {
+ private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
ZStack(alignment: .topTrailing) {
text
.lineLimit(2)
@@ -114,6 +111,8 @@ struct ChatPreviewView: View {
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
+ .privacySensitive(!showChatPreviews && !draft)
+ .redacted(reason: .privacy)
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
@@ -175,14 +174,18 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
- chatPreviewLayout(messageDraft(draft))
+ chatPreviewLayout(messageDraft(draft), draft: true)
} else if let cItem = cItem {
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
} else {
switch (chat.chatInfo) {
case let .direct(contact):
if !contact.ready {
- chatPreviewInfoText("connecting…")
+ if contact.nextSendGrpInv {
+ chatPreviewInfoText("send direct message")
+ } else {
+ chatPreviewInfoText("connecting…")
+ }
}
case let .group(groupInfo):
switch (groupInfo.membership.memberStatus) {
@@ -198,10 +201,7 @@ struct ChatPreviewView: View {
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
groupInfo.membership.memberIncognito
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
- : (chatModel.incognito
- ? chatPreviewInfoText("join as \(chatModel.currentUser?.profile.displayName ?? "yourself")")
- : chatPreviewInfoText("you are invited to group")
- )
+ : chatPreviewInfoText("you are invited to group")
}
@ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
@@ -229,7 +229,7 @@ struct ChatPreviewView: View {
switch chat.chatInfo {
case let .direct(contact):
switch (chatModel.contactNetworkStatus(contact)) {
- case .connected: EmptyView()
+ case .connected: incognitoIcon(chat.chatInfo.incognito)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
@@ -240,11 +240,23 @@ struct ChatPreviewView: View {
ProgressView()
}
default:
- EmptyView()
+ incognitoIcon(chat.chatInfo.incognito)
}
}
}
+@ViewBuilder func incognitoIcon(_ incognito: Bool) -> some View {
+ if incognito {
+ Image(systemName: "theatermasks")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 22, height: 22)
+ .foregroundColor(.secondary)
+ } else {
+ EmptyView()
+ }
+}
+
func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
}
@@ -258,20 +270,20 @@ struct ChatPreviewView_Previews: PreviewProvider {
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
- chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)]
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))]
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
- chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
- chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now))]
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))]
))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
- chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)],
+ chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0)
))
ChatPreviewView(chat: Chat(
diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
index 7d850719d6..3e42d2f207 100644
--- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
+++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift
@@ -15,6 +15,7 @@ struct ContactConnectionInfo: View {
@State var contactConnection: PendingContactConnection
@State private var alert: CCInfoAlert?
@State private var localAlias = ""
+ @State private var showIncognitoSheet = false
@FocusState private var aliasTextFieldFocused: Bool
enum CCInfoAlert: Identifiable {
@@ -31,19 +32,14 @@ struct ContactConnectionInfo: View {
var body: some View {
NavigationView {
- List {
+ let v = List {
Group {
- Text(contactConnection.initiated ? "You invited your contact" : "You accepted connection")
+ Text(contactConnection.initiated ? "You invited a contact" : "You accepted connection")
.font(.largeTitle)
.bold()
- .padding(.bottom, 16)
+ .padding(.bottom)
Text(contactConnectionText(contactConnection))
- .padding(.bottom, 16)
-
- if let connReqInv = contactConnection.connReqInv {
- OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInv)
- }
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
@@ -65,10 +61,16 @@ struct ContactConnectionInfo: View {
if contactConnection.initiated,
let connReqInv = contactConnection.connReqInv {
- oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInv)
+ QRCode(uri: connReqInv)
+ incognitoEnabled()
+ shareLinkButton(connReqInv)
+ oneTimeLinkLearnMoreButton()
} else {
+ incognitoEnabled()
oneTimeLinkLearnMoreButton()
}
+ } footer: {
+ sharedProfileInfo(contactConnection.incognito)
}
Section {
@@ -80,6 +82,14 @@ struct ContactConnectionInfo: View {
}
}
}
+ if #available(iOS 16, *) {
+ v
+ } else {
+ // navigationBarHidden is added conditionally,
+ // because the view jumps in iOS 17 if this is added,
+ // and on iOS 16+ it is hidden without it.
+ v.navigationBarHidden(true)
+ }
}
.alert(item: $alert) { _alert in
switch _alert {
@@ -128,6 +138,30 @@ struct ContactConnectionInfo: View {
)
: "You will be connected when your contact's device is online, please wait or check later!"
}
+
+ @ViewBuilder private func incognitoEnabled() -> some View {
+ if contactConnection.incognito {
+ ZStack(alignment: .leading) {
+ Image(systemName: "theatermasks.fill")
+ .frame(maxWidth: 24, maxHeight: 24, alignment: .center)
+ .foregroundColor(Color.indigo)
+ .font(.system(size: 14))
+ HStack(spacing: 6) {
+ Text("Incognito")
+ Image(systemName: "info.circle")
+ .foregroundColor(.accentColor)
+ .font(.system(size: 14))
+ }
+ .onTapGesture {
+ showIncognitoSheet = true
+ }
+ .padding(.leading, 36)
+ }
+ .sheet(isPresented: $showIncognitoSheet) {
+ IncognitoHelp()
+ }
+ }
+ }
}
struct ContactConnectionInfo_Previews: PreviewProvider {
diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift
index 1cd50c606d..d21f347881 100644
--- a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift
+++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift
@@ -58,10 +58,14 @@ struct ContactConnectionView: View {
}
.padding(.bottom, 2)
- Text(contactConnection.description)
- .frame(alignment: .topLeading)
- .padding(.horizontal, 8)
- .padding(.bottom, 2)
+ ZStack(alignment: .topTrailing) {
+ Text(contactConnection.description)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ incognitoIcon(contactConnection.incognito)
+ .padding(.top, 26)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ .padding(.horizontal, 8)
Spacer()
}
diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
index c40f672877..c5c062a6ec 100644
--- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
+++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift
@@ -24,7 +24,7 @@ struct ContactRequestView: View {
Text(contactRequest.chatViewName)
.font(.title3)
.fontWeight(.bold)
- .foregroundColor(chatModel.incognito ? .indigo : .accentColor)
+ .foregroundColor(.accentColor)
.padding(.leading, 8)
.frame(alignment: .topLeading)
Spacer()
diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
index 21b968fde9..1b44c23135 100644
--- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift
+++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift
@@ -133,7 +133,7 @@ struct LibraryMediaListPicker: UIViewControllerRepresentable {
config.filter = .any(of: [.images, .videos])
config.selectionLimit = selectionLimit
config.selection = .ordered
- //config.preferredAssetRepresentationMode = .current
+ config.preferredAssetRepresentationMode = .current
let controller = PHPickerViewController(configuration: config)
controller.delegate = context.coordinator
return controller
diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
index 15883f8340..936c6cb3ab 100644
--- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift
+++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift
@@ -8,11 +8,15 @@
import SwiftUI
-func showShareSheet(items: [Any]) {
+func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController {
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
+ if let completed = completed {
+ let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() }
+ activityViewController.completionWithItemsHandler = handler
+ }
presentedViewController.present(activityViewController, animated: true)
}
}
diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift
index 44de3c4987..31b6b64f32 100644
--- a/apps/ios/Shared/Views/NewChat/AddContactView.swift
+++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift
@@ -12,38 +12,92 @@ import SimpleXChat
struct AddContactView: View {
@EnvironmentObject private var chatModel: ChatModel
- var contactConnection: PendingContactConnection? = nil
+ @Binding var contactConnection: PendingContactConnection?
var connReqInvitation: String
+ @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
var body: some View {
- List {
- OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
- .listRowBackground(Color.clear)
- .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
-
- Section("1-time link") {
- oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
+ VStack {
+ List {
+ Section {
+ if connReqInvitation != "" {
+ QRCode(uri: connReqInvitation)
+ } else {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .scaleEffect(2)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical)
+ }
+ IncognitoToggle(incognitoEnabled: $incognitoDefault)
+ .disabled(contactConnection == nil)
+ shareLinkButton(connReqInvitation)
+ oneTimeLinkLearnMoreButton()
+ } header: {
+ Text("1-time link")
+ } footer: {
+ sharedProfileInfo(incognitoDefault)
+ }
}
}
.onAppear { chatModel.connReqInv = connReqInvitation }
+ .onChange(of: incognitoDefault) { incognito in
+ Task {
+ do {
+ if let contactConn = contactConnection,
+ let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
+ await MainActor.run {
+ contactConnection = conn
+ ChatModel.shared.updateContactConnection(conn)
+ }
+ }
+ } catch {
+ logger.error("apiSetConnectionIncognito error: \(responseError(error))")
+ }
+ }
+ }
}
}
-@ViewBuilder func oneTimeLinkSection(contactConnection: PendingContactConnection? = nil, connReqInvitation: String) -> some View {
- if connReqInvitation != "" {
- QRCode(uri: connReqInvitation)
- } else {
- ProgressView()
- .progressViewStyle(.circular)
- .scaleEffect(2)
- .frame(maxWidth: .infinity)
- .padding(.vertical)
+struct IncognitoToggle: View {
+ @Binding var incognitoEnabled: Bool
+ @State private var showIncognitoSheet = false
+
+ var body: some View {
+ ZStack(alignment: .leading) {
+ Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks")
+ .frame(maxWidth: 24, maxHeight: 24, alignment: .center)
+ .foregroundColor(incognitoEnabled ? Color.indigo : .secondary)
+ .font(.system(size: 14))
+ Toggle(isOn: $incognitoEnabled) {
+ HStack(spacing: 6) {
+ Text("Incognito")
+ Image(systemName: "info.circle")
+ .foregroundColor(.accentColor)
+ .font(.system(size: 14))
+ }
+ .onTapGesture {
+ showIncognitoSheet = true
+ }
+ }
+ .padding(.leading, 36)
+ }
+ .sheet(isPresented: $showIncognitoSheet) {
+ IncognitoHelp()
+ }
}
- shareLinkButton(connReqInvitation)
- oneTimeLinkLearnMoreButton()
}
-private func shareLinkButton(_ connReqInvitation: String) -> some View {
+func sharedProfileInfo(_ incognito: Bool) -> Text {
+ let name = ChatModel.shared.currentUser?.displayName ?? ""
+ return Text(
+ incognito
+ ? "A new random profile will be shared."
+ : "Your profile **\(name)** will be shared."
+ )
+}
+
+func shareLinkButton(_ connReqInvitation: String) -> some View {
Button {
showShareSheet(items: [connReqInvitation])
} label: {
@@ -65,26 +119,11 @@ func oneTimeLinkLearnMoreButton() -> some View {
}
}
-struct OneTimeLinkProfileText: View {
- @EnvironmentObject private var chatModel: ChatModel
- var contactConnection: PendingContactConnection? = nil
- var connReqInvitation: String
-
- var body: some View {
- HStack {
- if (contactConnection?.incognito ?? chatModel.incognito) {
- Image(systemName: "theatermasks").foregroundColor(.indigo)
- Text("A random profile will be sent to your contact")
- } else {
- Image(systemName: "info.circle").foregroundColor(.secondary)
- Text("Your chat profile will be sent to your contact")
- }
- }
- }
-}
-
struct AddContactView_Previews: PreviewProvider {
static var previews: some View {
- AddContactView(connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D")
+ AddContactView(
+ contactConnection: Binding.constant(PendingContactConnection.getSampleData()),
+ connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D"
+ )
}
}
diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift
index 247b91a04a..8df37bb560 100644
--- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift
+++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift
@@ -47,21 +47,13 @@ struct AddGroupView: View {
.padding(.vertical, 4)
Text("The group is fully decentralized – it is visible only to the members.")
.padding(.bottom, 4)
- if (m.incognito) {
- HStack {
- Image(systemName: "info.circle").foregroundColor(.orange).font(.footnote)
- Spacer().frame(width: 8)
- Text("Incognito mode is not supported here - your main profile will be sent to group members").font(.footnote)
- }
- .padding(.bottom)
- } else {
- HStack {
- Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
- Spacer().frame(width: 8)
- Text("Your chat profile will be sent to group members").font(.footnote)
- }
- .padding(.bottom)
+
+ HStack {
+ Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
+ Spacer().frame(width: 8)
+ Text("Your chat profile will be sent to group members").font(.footnote)
}
+ .padding(.bottom)
ZStack(alignment: .center) {
ZStack(alignment: .topTrailing) {
diff --git a/apps/ios/Shared/Views/NewChat/CreateLinkView.swift b/apps/ios/Shared/Views/NewChat/CreateLinkView.swift
index 71daf88a8c..0b9cfe7a17 100644
--- a/apps/ios/Shared/Views/NewChat/CreateLinkView.swift
+++ b/apps/ios/Shared/Views/NewChat/CreateLinkView.swift
@@ -7,6 +7,7 @@
//
import SwiftUI
+import SimpleXChat
enum CreateLinkTab {
case oneTime
@@ -24,6 +25,7 @@ struct CreateLinkView: View {
@EnvironmentObject var m: ChatModel
@State var selection: CreateLinkTab
@State var connReqInvitation: String = ""
+ @State var contactConnection: PendingContactConnection? = nil
@State private var creatingConnReq = false
var viaNavLink = false
@@ -39,7 +41,7 @@ struct CreateLinkView: View {
private func createLinkView() -> some View {
TabView(selection: $selection) {
- AddContactView(connReqInvitation: connReqInvitation)
+ AddContactView(contactConnection: $contactConnection, connReqInvitation: connReqInvitation)
.tabItem {
Label(
connReqInvitation == ""
@@ -56,7 +58,7 @@ struct CreateLinkView: View {
.tag(CreateLinkTab.longTerm)
}
.onChange(of: selection) { _ in
- if case .oneTime = selection, connReqInvitation == "" && !creatingConnReq {
+ if case .oneTime = selection, connReqInvitation == "", contactConnection == nil && !creatingConnReq {
createInvitation()
}
}
@@ -69,12 +71,14 @@ struct CreateLinkView: View {
private func createInvitation() {
creatingConnReq = true
Task {
- let connReq = await apiAddContact()
- await MainActor.run {
- if let connReq = connReq {
+ if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
+ await MainActor.run {
connReqInvitation = connReq
+ contactConnection = pcc
m.connReqInv = connReq
- } else {
+ }
+ } else {
+ await MainActor.run {
creatingConnReq = false
}
}
diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift
index 3486ab6ef8..a727ad6be0 100644
--- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift
+++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift
@@ -10,13 +10,13 @@ import SwiftUI
import SimpleXChat
enum NewChatAction: Identifiable {
- case createLink(link: String)
+ case createLink(link: String, connection: PendingContactConnection)
case connectViaLink
case createGroup
var id: String {
switch self {
- case let .createLink(link): return "createLink \(link)"
+ case let .createLink(link, _): return "createLink \(link)"
case .connectViaLink: return "connectViaLink"
case .createGroup: return "createGroup"
}
@@ -41,8 +41,8 @@ struct NewChatButton: View {
}
.sheet(item: $actionSheet) { sheet in
switch sheet {
- case let .createLink(link):
- CreateLinkView(selection: .oneTime, connReqInvitation: link)
+ case let .createLink(link, pcc):
+ CreateLinkView(selection: .oneTime, connReqInvitation: link, contactConnection: pcc)
case .connectViaLink: ConnectViaLinkView()
case .createGroup: AddGroupView()
}
@@ -51,8 +51,8 @@ struct NewChatButton: View {
func addContactAction() {
Task {
- if let connReq = await apiAddContact() {
- actionSheet = .createLink(link: connReq)
+ if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
+ actionSheet = .createLink(link: connReq, connection: pcc)
}
}
}
@@ -63,9 +63,9 @@ enum ConnReqType: Equatable {
case invitation
}
-func connectViaLink(_ connectionLink: String, _ dismiss: DismissAction? = nil) {
+func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) {
Task {
- if let connReqType = await apiConnect(connReq: connectionLink) {
+ if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
DispatchQueue.main.async {
dismiss?()
AlertManager.shared.showAlert(connReqSentAlert(connReqType))
@@ -100,12 +100,12 @@ func checkCRDataGroup(_ crData: CReqClientData) -> Bool {
return crData.type == "group" && crData.groupLinkId != nil
}
-func groupLinkAlert(_ connectionLink: String) -> Alert {
+func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert {
return Alert(
title: Text("Connect via group link?"),
message: Text("You will join a group this link refers to and connect to its group members."),
- primaryButton: .default(Text("Connect")) {
- connectViaLink(connectionLink)
+ primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) {
+ connectViaLink(connectionLink, incognito: incognito)
},
secondaryButton: .cancel()
)
diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift
index de390ebad9..ec199327ce 100644
--- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift
+++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift
@@ -7,76 +7,77 @@
//
import SwiftUI
+import SimpleXChat
struct PasteToConnectView: View {
- @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@State private var connectionLink: String = ""
+ @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
+ @FocusState private var linkEditorFocused: Bool
var body: some View {
- ScrollView {
- VStack(alignment: .leading) {
- Text("Connect via link")
- .font(.largeTitle)
- .bold()
- .fixedSize(horizontal: false, vertical: true)
- .padding(.vertical)
- Text("Paste the link you received into the box below to connect with your contact.")
- .padding(.bottom, 4)
- if (chatModel.incognito) {
- HStack {
- Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
- Spacer().frame(width: 8)
- Text("A random profile will be sent to the contact that you received this link from").font(.footnote)
+ List {
+ Text("Connect via link")
+ .font(.largeTitle)
+ .bold()
+ .fixedSize(horizontal: false, vertical: true)
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
+ .onTapGesture { linkEditorFocused = false }
+
+ Section {
+ linkEditor()
+
+ Button {
+ if connectionLink == "" {
+ connectionLink = UIPasteboard.general.string ?? ""
+ } else {
+ connectionLink = ""
}
- .padding(.bottom)
- } else {
- HStack {
- Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
- Spacer().frame(width: 8)
- Text("Your profile will be sent to the contact that you received this link from").font(.footnote)
+ } label: {
+ if connectionLink == "" {
+ settingsRow("doc.plaintext") { Text("Paste") }
+ } else {
+ settingsRow("multiply") { Text("Clear") }
}
- .padding(.bottom)
+ }
+
+ Button {
+ connect()
+ } label: {
+ settingsRow("link") { Text("Connect") }
+ }
+ .disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
+
+ IncognitoToggle(incognitoEnabled: $incognitoDefault)
+ } footer: {
+ sharedProfileInfo(incognitoDefault)
+ + Text(String("\n\n"))
+ + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
+ }
+ }
+ }
+
+ private func linkEditor() -> some View {
+ ZStack {
+ Group {
+ if connectionLink.isEmpty {
+ TextEditor(text: Binding.constant(NSLocalizedString("Paste the link you received to connect with your contact.", comment: "placeholder")))
+ .foregroundColor(.secondary)
+ .disabled(true)
}
TextEditor(text: $connectionLink)
.onSubmit(connect)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
- .allowsTightening(false)
- .frame(height: 180)
- .overlay(
- RoundedRectangle(cornerRadius: 10)
- .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
- )
-
- HStack(spacing: 20) {
- if connectionLink == "" {
- Button {
- connectionLink = UIPasteboard.general.string ?? ""
- } label: {
- Label("Paste", systemImage: "doc.plaintext")
- }
- } else {
- Button {
- connectionLink = ""
- } label: {
- Label("Clear", systemImage: "multiply")
- }
-
- }
- Spacer()
- Button(action: connect, label: {
- Label("Connect", systemImage: "link")
- })
- .disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil)
- }
- .frame(height: 48)
- .padding(.bottom)
-
- Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
+ .focused($linkEditorFocused)
}
- .padding()
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .allowsTightening(false)
+ .padding(.horizontal, -5)
+ .padding(.top, -8)
+ .frame(height: 180, alignment: .topLeading)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -85,9 +86,9 @@ struct PasteToConnectView: View {
if let crData = parseLinkQueryData(link),
checkCRDataGroup(crData) {
dismiss()
- AlertManager.shared.showAlert(groupLinkAlert(link))
+ AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault))
} else {
- connectViaLink(link, dismiss)
+ connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault)
}
}
}
diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
index 2213dff203..01c8a4659e 100644
--- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
+++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift
@@ -7,11 +7,12 @@
//
import SwiftUI
+import SimpleXChat
import CodeScanner
struct ScanToConnectView: View {
- @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
+ @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
var body: some View {
ScrollView {
@@ -19,34 +20,35 @@ struct ScanToConnectView: View {
Text("Scan QR code")
.font(.largeTitle)
.bold()
+ .fixedSize(horizontal: false, vertical: true)
.padding(.vertical)
- if (chatModel.incognito) {
- HStack {
- Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
- Spacer().frame(width: 8)
- Text("A random profile will be sent to your contact").font(.footnote)
- }
- .padding(.bottom)
- } else {
- HStack {
- Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
- Spacer().frame(width: 8)
- Text("Your chat profile will be sent to your contact").font(.footnote)
- }
- .padding(.bottom)
+
+ CodeScannerView(codeTypes: [.qr], completion: processQRCode)
+ .aspectRatio(1, contentMode: .fit)
+ .cornerRadius(12)
+
+ IncognitoToggle(incognitoEnabled: $incognitoDefault)
+ .padding(.horizontal)
+ .padding(.vertical, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color(uiColor: .systemBackground))
+ )
+ .padding(.top)
+
+ Group {
+ sharedProfileInfo(incognitoDefault)
+ + Text(String("\n\n"))
+ + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
}
- ZStack {
- CodeScannerView(codeTypes: [.qr], completion: processQRCode)
- .aspectRatio(1, contentMode: .fit)
- .border(.gray)
- }
- .padding(.bottom)
- Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
- .padding(.bottom)
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ .padding(.horizontal)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
+ .background(Color(.systemGroupedBackground))
}
func processQRCode(_ resp: Result) {
@@ -55,9 +57,9 @@ struct ScanToConnectView: View {
if let crData = parseLinkQueryData(r.string),
checkCRDataGroup(crData) {
dismiss()
- AlertManager.shared.showAlert(groupLinkAlert(r.string))
+ AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault))
} else {
- Task { connectViaLink(r.string, dismiss) }
+ Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) }
}
case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
index 83ab69278a..966284b0c9 100644
--- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
+++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift
@@ -251,7 +251,38 @@ private let versionDescriptions: [VersionDescription] = [
description: "- more stable message delivery.\n- a bit better groups.\n- and more!"
),
]
- )
+ ),
+ VersionDescription(
+ version: "v5.3",
+ post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"),
+ features: [
+ FeatureDescription(
+ icon: "desktopcomputer",
+ title: "New desktop app!",
+ description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻"
+ ),
+ FeatureDescription(
+ icon: "lock",
+ title: "Encrypt stored files & media",
+ description: "App encrypts new local files (except videos)."
+ ),
+ FeatureDescription(
+ icon: "magnifyingglass",
+ title: "Discover and join groups",
+ description: "- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable."
+ ),
+ FeatureDescription(
+ icon: "theatermasks",
+ title: "Simplified incognito mode",
+ description: "Toggle incognito when connecting."
+ ),
+ FeatureDescription(
+ icon: "character",
+ title: "\(4) new interface languages",
+ description: "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
+ ),
+ ]
+ ),
]
private let lastVersion = versionDescriptions.last!.version
@@ -321,12 +352,15 @@ struct WhatsNewView: View {
private func featureDescription(_ icon: String, _ title: LocalizedStringKey, _ description: LocalizedStringKey) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .center, spacing: 4) {
- Image(systemName: icon).foregroundColor(.secondary)
+ Image(systemName: icon)
+ .symbolRenderingMode(.monochrome)
+ .foregroundColor(.secondary)
.frame(minWidth: 30, alignment: .center)
Text(title).font(.title3).bold()
}
Text(description)
.multilineTextAlignment(.leading)
+ .lineLimit(10)
}
}
diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift
index be6ccfd3df..94a8937db6 100644
--- a/apps/ios/Shared/Views/TerminalView.swift
+++ b/apps/ios/Shared/Views/TerminalView.swift
@@ -22,10 +22,28 @@ struct TerminalView: View {
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
@State private var terminalItem: TerminalItem?
@State private var scrolled = false
+ @State private var showing = false
var body: some View {
if authorized {
terminalView()
+ .onAppear {
+ if showing { return }
+ showing = true
+ Task {
+ let items = await TerminalItems.shared.items()
+ await MainActor.run {
+ chatModel.terminalItems = items
+ chatModel.showingTerminal = true
+ }
+ }
+ }
+ .onDisappear {
+ if terminalItem == nil {
+ chatModel.showingTerminal = false
+ chatModel.terminalItems = []
+ }
+ }
} else {
Button(action: runAuth) { Label("Unlock", systemImage: "lock") }
.onAppear(perform: runAuth)
@@ -118,9 +136,8 @@ struct TerminalView: View {
let cmd = ChatCommand.string(composeState.message)
if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) {
let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
- DispatchQueue.main.async {
- ChatModel.shared.addTerminalItem(.cmd(.now, cmd))
- ChatModel.shared.addTerminalItem(.resp(.now, resp))
+ Task {
+ await TerminalItems.shared.addCommand(.now, cmd, resp)
}
} else {
DispatchQueue.global().async {
diff --git a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift
index 554daaebb1..8e8885b518 100644
--- a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift
@@ -53,7 +53,7 @@ struct AdvancedNetworkSettings: View {
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel)
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
- timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [10_000, 20_000, 40_000, 75_000, 100_000], label: secondsLabel)
+ timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 60_000, 90_000, 120_000], label: secondsLabel)
timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel)
intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "")
Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive)
diff --git a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift
index 3652dec054..20dadb7954 100644
--- a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift
+++ b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift
@@ -18,10 +18,9 @@ struct IncognitoHelp: View {
ScrollView {
VStack(alignment: .leading) {
Group {
- Text("Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.")
+ Text("Incognito mode protects your privacy by using a new random profile for each contact.")
Text("It allows having many anonymous connections without any shared data between them in a single chat profile.")
Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.")
- Text("To find the profile used for an incognito connection, tap the contact or group name on top of the chat.")
}
.padding(.bottom)
}
diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift
index cf8978498b..960afb6d38 100644
--- a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift
+++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift
@@ -71,9 +71,10 @@ struct PreferencesView: View {
do {
var p = fromLocalProfile(profile)
p.preferences = fullPreferencesToPreferences(preferences)
- if let newProfile = try await apiUpdateProfile(profile: p) {
+ if let (newProfile, updatedContacts) = try await apiUpdateProfile(profile: p) {
await MainActor.run {
chatModel.updateCurrentUser(newProfile, preferences)
+ updatedContacts.forEach(chatModel.updateContact)
currentPreferences = preferences
}
}
diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
index 789a5330e5..34b6f147bd 100644
--- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
+++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift
@@ -13,6 +13,9 @@ struct PrivacySettings: View {
@EnvironmentObject var m: ChatModel
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
+ @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
+ @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
+ @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@@ -21,6 +24,10 @@ struct PrivacySettings: View {
@State private var contactReceiptsReset = false
@State private var contactReceiptsOverrides = 0
@State private var contactReceiptsDialogue = false
+ @State private var groupReceipts = false
+ @State private var groupReceiptsReset = false
+ @State private var groupReceiptsOverrides = 0
+ @State private var groupReceiptsDialogue = false
@State private var alert: PrivacySettingsViewAlert?
enum PrivacySettingsViewAlert: Identifiable {
@@ -57,6 +64,9 @@ struct PrivacySettings: View {
}
Section {
+ settingsRow("lock.doc") {
+ Toggle("Encrypt local files", isOn: $encryptLocalFiles)
+ }
settingsRow("photo") {
Toggle("Auto-accept images", isOn: $autoAcceptImages)
.onChange(of: autoAcceptImages) {
@@ -66,6 +76,18 @@ struct PrivacySettings: View {
settingsRow("network") {
Toggle("Send link previews", isOn: $useLinkPreviews)
}
+ settingsRow("message") {
+ Toggle("Show last messages", isOn: $showChatPreviews)
+ }
+ settingsRow("rectangle.and.pencil.and.ellipsis") {
+ Toggle("Message draft", isOn: $saveLastDraft)
+ }
+ .onChange(of: saveLastDraft) { saveDraft in
+ if !saveDraft {
+ m.draft = nil
+ m.draftChatId = nil
+ }
+ }
settingsRow("link") {
Picker("SimpleX links", selection: $simplexLinkMode) {
ForEach(SimpleXLinkMode.values) { mode in
@@ -89,15 +111,15 @@ struct PrivacySettings: View {
settingsRow("person") {
Toggle("Contacts", isOn: $contactReceipts)
}
-// settingsRow("person.2") {
-// Toggle("Small groups (max 20)", isOn: Binding.constant(false))
-// }
+ settingsRow("person.2") {
+ Toggle("Small groups (max 20)", isOn: $groupReceipts)
+ }
} header: {
Text("Send delivery receipts to")
} footer: {
VStack(alignment: .leading) {
Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
- Text("They can be overridden in contact settings")
+ Text("They can be overridden in contact and group settings.")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -113,19 +135,44 @@ struct PrivacySettings: View {
contactReceipts.toggle()
}
}
+ .confirmationDialog(groupReceiptsDialogTitle, isPresented: $groupReceiptsDialogue, titleVisibility: .visible) {
+ Button(groupReceipts ? "Enable (keep overrides)" : "Disable (keep overrides)") {
+ setSendReceiptsGroups(groupReceipts, clearOverrides: false)
+ }
+ Button(groupReceipts ? "Enable for all" : "Disable for all", role: .destructive) {
+ setSendReceiptsGroups(groupReceipts, clearOverrides: true)
+ }
+ Button("Cancel", role: .cancel) {
+ groupReceiptsReset = true
+ groupReceipts.toggle()
+ }
+ }
}
}
- .onChange(of: contactReceipts) { _ in // sometimes there is race with onAppear
+ .onChange(of: contactReceipts) { _ in
if contactReceiptsReset {
contactReceiptsReset = false
} else {
setOrAskSendReceiptsContacts(contactReceipts)
}
}
+ .onChange(of: groupReceipts) { _ in
+ if groupReceiptsReset {
+ groupReceiptsReset = false
+ } else {
+ setOrAskSendReceiptsGroups(groupReceipts)
+ }
+ }
.onAppear {
- if let u = m.currentUser, contactReceipts != u.sendRcptsContacts {
- contactReceiptsReset = true
- contactReceipts = u.sendRcptsContacts
+ if let u = m.currentUser {
+ if contactReceipts != u.sendRcptsContacts {
+ contactReceiptsReset = true
+ contactReceipts = u.sendRcptsContacts
+ }
+ if groupReceipts != u.sendRcptsSmallGroups {
+ groupReceiptsReset = true
+ groupReceipts = u.sendRcptsSmallGroups
+ }
}
}
.alert(item: $alert) { alert in
@@ -184,6 +231,54 @@ struct PrivacySettings: View {
}
}
+ private func setOrAskSendReceiptsGroups(_ enable: Bool) {
+ groupReceiptsOverrides = m.chats.reduce(0) { count, chat in
+ let sendRcpts = chat.chatInfo.groupInfo?.chatSettings.sendRcpts
+ return count + (sendRcpts == nil || sendRcpts == enable ? 0 : 1)
+ }
+ if groupReceiptsOverrides == 0 {
+ setSendReceiptsGroups(enable, clearOverrides: false)
+ } else {
+ groupReceiptsDialogue = true
+ }
+ }
+
+ private var groupReceiptsDialogTitle: LocalizedStringKey {
+ groupReceipts
+ ? "Sending receipts is disabled for \(groupReceiptsOverrides) groups"
+ : "Sending receipts is enabled for \(groupReceiptsOverrides) groups"
+ }
+
+ private func setSendReceiptsGroups(_ enable: Bool, clearOverrides: Bool) {
+ Task {
+ do {
+ if let currentUser = m.currentUser {
+ let userMsgReceiptSettings = UserMsgReceiptSettings(enable: enable, clearOverrides: clearOverrides)
+ try await apiSetUserGroupReceipts(currentUser.userId, userMsgReceiptSettings: userMsgReceiptSettings)
+ privacyDeliveryReceiptsSet.set(true)
+ await MainActor.run {
+ var updatedUser = currentUser
+ updatedUser.sendRcptsSmallGroups = enable
+ m.updateUser(updatedUser)
+ if clearOverrides {
+ m.chats.forEach { chat in
+ if var groupInfo = chat.chatInfo.groupInfo {
+ let sendRcpts = groupInfo.chatSettings.sendRcpts
+ if sendRcpts != nil && sendRcpts != enable {
+ groupInfo.chatSettings.sendRcpts = nil
+ m.updateGroup(groupInfo)
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch let error {
+ alert = .error(title: "Error setting delivery receipts!", error: "Error: \(responseError(error))")
+ }
+ }
+ }
+
private func simplexLockRow(_ value: LocalizedStringKey) -> some View {
HStack {
Text("SimpleX Lock")
diff --git a/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift
index c0ad4e3e18..ffdbd1b07e 100644
--- a/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift
+++ b/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift
@@ -21,11 +21,10 @@ struct ScanProtocolServer: View {
.font(.largeTitle)
.bold()
.padding(.vertical)
- ZStack {
- CodeScannerView(codeTypes: [.qr], completion: processQRCode)
- .aspectRatio(1, contentMode: .fit)
- .border(.gray)
- }
+ CodeScannerView(codeTypes: [.qr], completion: processQRCode)
+ .aspectRatio(1, contentMode: .fit)
+ .cornerRadius(12)
+ .padding(.top)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index 7ca18692a8..d6681a51cd 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -30,6 +30,8 @@ let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
+let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
+let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
@@ -65,6 +67,8 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
DEFAULT_PRIVACY_LINK_PREVIEWS: true,
DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue,
+ DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true,
+ DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
DEFAULT_EXPERIMENTAL_CALLS: false,
@@ -131,7 +135,6 @@ struct SettingsView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var sceneDelegate: SceneDelegate
@Binding var showSettings: Bool
- @State private var settingsSheet: SettingsSheet?
var body: some View {
ZStack {
@@ -161,8 +164,6 @@ struct SettingsView: View {
settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") }
}
- incognitoRow()
-
NavigationLink {
UserAddressView(shareViaProfile: chatModel.currentUser!.addressShared)
.navigationTitle("SimpleX address")
@@ -298,38 +299,9 @@ struct SettingsView: View {
}
.navigationTitle("Your settings")
}
- .sheet(item: $settingsSheet) { sheet in
- switch sheet {
- case .incognitoInfo: IncognitoHelp()
- }
- }
- }
-
- @ViewBuilder private func incognitoRow() -> some View {
- ZStack(alignment: .leading) {
- Image(systemName: chatModel.incognito ? "theatermasks.fill" : "theatermasks")
- .frame(maxWidth: 24, maxHeight: 24, alignment: .center)
- .foregroundColor(chatModel.incognito ? Color.indigo : .secondary)
- Toggle(isOn: $chatModel.incognito) {
- HStack(spacing: 6) {
- Text("Incognito")
- Image(systemName: "info.circle")
- .foregroundColor(.accentColor)
- .font(.system(size: 14))
- }
- .onTapGesture {
- settingsSheet = .incognitoInfo
- }
- }
- .onChange(of: chatModel.incognito) { incognito in
- incognitoGroupDefault.set(incognito)
- do {
- try apiSetIncognito(incognito: incognito)
- } catch {
- logger.error("apiSetIncognito: cannot set incognito \(responseError(error))")
- }
- }
- .padding(.leading, indent)
+ .onDisappear {
+ chatModel.showingTerminal = false
+ chatModel.terminalItems = []
}
}
@@ -351,12 +323,6 @@ struct SettingsView: View {
}
}
- private enum SettingsSheet: Identifiable {
- case incognitoInfo
-
- var id: SettingsSheet { get { self } }
- }
-
private enum NotificationAlert {
case enable
case error(LocalizedStringKey, String)
diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
index 134eeb969b..86bf0048b8 100644
--- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
@@ -190,7 +190,7 @@ struct UserAddressView: View {
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
Section {
- QRCode(uri: userAddress.connReqContact)
+ MutableQRCode(uri: Binding.constant(userAddress.connReqContact))
shareQRCodeButton(userAddress)
if MFMailComposeViewController.canSendMail() {
shareViaEmailButton(userAddress)
diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
index 9ea6fe2a7f..f38dc593a6 100644
--- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift
+++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift
@@ -144,7 +144,7 @@ struct UserProfile: View {
func saveProfile() {
Task {
do {
- if let newProfile = try await apiUpdateProfile(profile: profile) {
+ if let (newProfile, _) = try await apiUpdateProfile(profile: profile) {
DispatchQueue.main.async {
chatModel.updateCurrentUser(newProfile)
profile = newProfile
diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
index 99da18e12c..e0477899be 100644
--- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
+++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff
@@ -3630,6 +3630,51 @@ SimpleX servers cannot see your profile.
\~strike~
No comment provided by engineer.
+
+ %@ servers
+ %@ الخوادم
+ No comment provided by engineer.
+
+
+ %@ (current)
+ %@ (الحالي)
+ No comment provided by engineer.
+
+
+ %@ (current):
+ %@ (الحالي):
+ copied message info
+
+
+ %@:
+ %@:
+ copied message info
+
+
+ %1$@ at %2$@:
+ %1$@ في %2$@:
+ copied message info, <sender> at <time>
+
+
+ # %@
+ # %@
+ copied message info title, # <title>
+
+
+ ## History
+ ## السجل
+ copied message info
+
+
+ ## In reply to
+ ## ردًا على
+ copied message info
+
+
+ %@ and %@ connected
+ %@ و %@ متصل
+ No comment provided by engineer.
+