mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
187 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0366afbcce | |||
| 4c33ed92bb | |||
| a0ae4125c5 | |||
| 34a60066fb | |||
| 1f50e94bc9 | |||
| 0e4376bada | |||
| b0ad94fe7f | |||
| fccd4f7ec4 | |||
| 3f93397031 | |||
| a5f8641d50 | |||
| f23c0b55f8 | |||
| 534151f1bb | |||
| 2ad9d0ddbc | |||
| 388bdc7083 | |||
| 3e370a7c16 | |||
| 77b3870654 | |||
| b088b1c44c | |||
| 8abad4f711 | |||
| 4c668f7a34 | |||
| 0bf5fbd641 | |||
| cfec60bf86 | |||
| 9caaab0e8e | |||
| 6da18d9b2a | |||
| da2622f00e | |||
| 7ed581dfbf | |||
| 78c0fe73a7 | |||
| 15b00f6110 | |||
| f592a26b00 | |||
| 30687f5fa6 | |||
| bc7217d686 | |||
| 8e0b3fa32e | |||
| d929c34e71 | |||
| ec7bff9205 | |||
| 22f20a9c5f | |||
| ddf81d28f1 | |||
| 5c105cb746 | |||
| e1370e8f3c | |||
| 9fbcc2b5bb | |||
| 53d77b25ed | |||
| e7089d4c2f | |||
| 6d3cb0ea2e | |||
| 46c6f5e615 | |||
| c29c3179a0 | |||
| 3e84429a3a | |||
| 904b6db628 | |||
| af4e94058a | |||
| 91b77b6d63 | |||
| 5a0c7c34bf | |||
| 3267b4d6ca | |||
| 9b302b856a | |||
| 4e696aed82 | |||
| 425c7b947f | |||
| d4f9429fc1 | |||
| 161b43e85d | |||
| d585e8f5a7 | |||
| 060e7cdf52 | |||
| 6fa002948e | |||
| bbd4e6c8ba | |||
| 92cf945e10 | |||
| cc0f55c245 | |||
| 22f27c4255 | |||
| 14a888bf43 | |||
| f6fddc9436 | |||
| f581e91f19 | |||
| fb72dfcdee | |||
| 925813b14c | |||
| abd410fe62 | |||
| 875282e9ec | |||
| 6afda28367 | |||
| 0721b24250 | |||
| 10b6bce8a2 | |||
| 0101444c5d | |||
| 128883b8a3 | |||
| cc75b75d4e | |||
| dea6cd81c7 | |||
| 2f53ab08b5 | |||
| d7f3d1f19d | |||
| a4517fcb9b | |||
| 4a12cf0922 | |||
| 0ee91b0280 | |||
| e131890f54 | |||
| bd069aea49 | |||
| 42d4f94fec | |||
| 3af2848275 | |||
| 8b1e5d3db7 | |||
| 57ed903a48 | |||
| 6093219ce9 | |||
| f9f34911b1 | |||
| 494328541a | |||
| fd2c7c888c | |||
| 24c09f2041 | |||
| a1e6d90e31 | |||
| de33fedea4 | |||
| 9f89104f94 | |||
| f0e88220c6 | |||
| 6d7e16d6e1 | |||
| d7d38fddb8 | |||
| 527a5bc6b5 | |||
| 9644dcb9b4 | |||
| f4861482f1 | |||
| dc73bb3caf | |||
| bcbfc1758e | |||
| e65dcf51b0 | |||
| 1326701440 | |||
| c32e45f686 | |||
| 0160684004 | |||
| 734b920fde | |||
| 174e703b4c | |||
| 36336a3a57 | |||
| c10a4346a9 | |||
| db55496fc7 | |||
| 9a2efd0ef0 | |||
| 579af09816 | |||
| d39614713d | |||
| 34af1e258c | |||
| 3ff68dbc7b | |||
| 8952ac9af0 | |||
| 7799a1e260 | |||
| 353927e6d2 | |||
| d40db1ddea | |||
| f85a9e174c | |||
| 838e14af60 | |||
| 1d84c5cad8 | |||
| 3be2259068 | |||
| acc4cad082 | |||
| 10a1788754 | |||
| f1c1059ff8 | |||
| 690c8ea2c9 | |||
| 1c8d1bc9ff | |||
| a9416d89e3 | |||
| 9e33ba46af | |||
| a0c4726af3 | |||
| a0b3c0a5a4 | |||
| 9978957e6c | |||
| f155611d29 | |||
| 01b3e98358 | |||
| 3a50da1b53 | |||
| a32fd5e665 | |||
| b6a4f5f518 | |||
| e799e80843 | |||
| 63cb7a75b3 | |||
| 922e95756a | |||
| b49f0d211b | |||
| 761fbf7757 | |||
| 4ee052e71e | |||
| 0274f3c2ac | |||
| ae13f1aa23 | |||
| 904405ebee | |||
| a059739210 | |||
| 25156bb56c | |||
| d62761b3a8 | |||
| 817c0a5672 | |||
| c06a970987 | |||
| baf3a12009 | |||
| 0ec2468dce | |||
| 0cfc9fd1fa | |||
| a2de9a3846 | |||
| 88059a2cc5 | |||
| 635d797b2e | |||
| e635c45ec6 | |||
| 594ae61192 | |||
| 2945f688fa | |||
| d86cca2e26 | |||
| fca315ee1f | |||
| a12f140333 | |||
| ad7e4488ef | |||
| df4e954f8a | |||
| 63f344bde6 | |||
| 0b8d9d11e2 | |||
| 57801fde1f | |||
| c87f4e68f7 | |||
| 27762492d7 | |||
| da7c408686 | |||
| 0c0a98605d | |||
| 108226bcdc | |||
| 8b400d4f2c | |||
| d838e7b44d | |||
| 7b157fa8e5 | |||
| d19a59a364 | |||
| b95a351222 | |||
| 1038acd2ea | |||
| 54fc052e47 | |||
| 62bac800af | |||
| aa2b36d5cc | |||
| b5f482bb50 | |||
| 649c104d29 | |||
| 41368c85bf |
@@ -1,7 +1,7 @@
|
||||
name: Bug
|
||||
description: File a bug report/issue
|
||||
title: "[Bug]: "
|
||||
labels: ["type:bug", "type:triage"]
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Feature
|
||||
description: Suggest your feature
|
||||
title: "[Feature]: "
|
||||
labels: ["type:enhancement", "type:triage"]
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Question
|
||||
description: Ask your question
|
||||
title: "[Q]: "
|
||||
labels: ["type:question", "type:triage"]
|
||||
labels: ["question", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
+19
-11
@@ -16,11 +16,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone project
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v1
|
||||
uses: mikepenz/release-changelog-builder-action@v4
|
||||
with:
|
||||
configuration: .github/changelog_conf.json
|
||||
failOnError: true
|
||||
@@ -62,17 +62,25 @@ jobs:
|
||||
cache_path: C:/cabal
|
||||
asset_name: simplex-chat-windows-x86-64
|
||||
steps:
|
||||
- name: Configure pagefile (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: al-cheb/configure-pagefile-action@v1.3
|
||||
with:
|
||||
minimum-size: 16GB
|
||||
maximum-size: 16GB
|
||||
disk-root: "C:"
|
||||
|
||||
- name: Clone project
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Haskell
|
||||
uses: haskell/actions/setup@v1
|
||||
uses: haskell/actions/setup@v2
|
||||
with:
|
||||
ghc-version: "8.10.7"
|
||||
cabal-version: "latest"
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ matrix.cache_path }}
|
||||
@@ -111,12 +119,6 @@ jobs:
|
||||
cabal build --enable-tests
|
||||
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest'
|
||||
timeout-minutes: 30
|
||||
shell: bash
|
||||
run: cabal test --test-show-details=direct
|
||||
|
||||
- name: Unix upload binary to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
@@ -126,6 +128,12 @@ jobs:
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Unix test
|
||||
if: matrix.os != 'windows-latest'
|
||||
timeout-minutes: 30
|
||||
shell: bash
|
||||
run: cabal test --test-show-details=direct
|
||||
|
||||
# Unix /
|
||||
|
||||
# / Windows
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [16.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
@@ -64,11 +64,18 @@ You must:
|
||||
|
||||
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
|
||||
|
||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-3](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fp-j-D_PrY2UMDchFHEUtbSES0nmzCnvD%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA3gBfMjB_GDEmKQwjNdqGbnX91yfuZ7nRJgQijsx5Khc%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%2262MvNZ_Ec2mmlS8V0QNtLQ%3D%3D%22%7D)
|
||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-4](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2Fw2GlucRXtRVgYnbt_9ZP-kmt76DekxxS%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0tJhTyMGUxznwmjb7aT24P1I1Wry_iURTuhOFlMb1Eo%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22WoPxjFqGEDlVazECOSi2dg%3D%3D%22%7D)
|
||||
|
||||
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) for developers who build on SimpleX platform:
|
||||
|
||||
- chat bots and automations
|
||||
- integrations with other apps
|
||||
- social apps and services
|
||||
- etc.
|
||||
|
||||
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
|
||||
|
||||
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
|
||||
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
|
||||
|
||||
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||
|
||||
@@ -101,8 +108,10 @@ Join our translators to help SimpleX grow!
|
||||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||
|🇯🇵 ja|Japanese ||[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|||
|
||||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||
|🇧🇷 pt-BR|Português||[](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|
||||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|
||||
@@ -112,6 +121,7 @@ Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https:/
|
||||
|
||||
We would love to have you join the development! You can help us with:
|
||||
|
||||
- [share the color theme](./docs/THEMES.md) you use in Android app!
|
||||
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
|
||||
- contributing to SimpleX Chat knowledge-base.
|
||||
- developing features - please connect to us via chat so we can help you get started.
|
||||
@@ -197,6 +207,8 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent updates:
|
||||
|
||||
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
|
||||
|
||||
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||
|
||||
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||
@@ -245,13 +257,15 @@ See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of m
|
||||
|
||||
SimpleX Chat is a work in progress – we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
|
||||
|
||||
We compiled a [glossary of terms](./docs/GLOSSARY.md) used to describe communication systems to help understand some terms below and to help compare advantages and disadvantages of various communication systems.
|
||||
|
||||
What is already implemented:
|
||||
|
||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||
2. End-to-end encryption in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
||||
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) 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 (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||
2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
||||
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 to frustrate message size attacks.
|
||||
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.
|
||||
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.
|
||||
@@ -276,6 +290,8 @@ You can:
|
||||
|
||||
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
||||
|
||||
Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) group to ask any questions and share your success stories.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
|
||||
@@ -316,25 +332,27 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
- ✅ Video messages.
|
||||
- ✅ App access passcode.
|
||||
- 🏗 Improved Android app UI design.
|
||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||
- 🏗 Reduced battery and traffic usage in large groups.
|
||||
- ✅ Improved Android app UI design.
|
||||
- ✅ Optional alternative access password.
|
||||
- ✅ Message reactions
|
||||
- ✅ Message editing history
|
||||
- ✅ Reduced battery and traffic usage in large groups.
|
||||
- 🏗 Desktop client.
|
||||
- 🏗 Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||
- SMP queue redundancy and rotation (manual is supported).
|
||||
- Include optional message into connection request sent via contact address.
|
||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Optional alternative access password.
|
||||
- Local app files encryption.
|
||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||
- Privately share your location.
|
||||
- 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.
|
||||
- Desktop client.
|
||||
- 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.
|
||||
- Hosting server for large groups, communities and public channels.
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- High capacity multi-node SMP relays.
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/misc.xml
|
||||
/.idea/uiDesigner.xml
|
||||
/.idea/kotlinc.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
app/src/main/cpp/libs/
|
||||
Generated
-1
@@ -1 +0,0 @@
|
||||
SimpleX
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-19
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,20 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,131 +0,0 @@
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.durationText
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun ComposeVoiceView(
|
||||
filePath: String,
|
||||
recordedDurationMs: Int,
|
||||
finishedRecording: Boolean,
|
||||
cancelEnabled: Boolean,
|
||||
cancelVoice: () -> Unit
|
||||
) {
|
||||
BoxWithConstraints(Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
val audioPlaying = rememberSaveable { mutableStateOf(false) }
|
||||
val progress = rememberSaveable { mutableStateOf(0) }
|
||||
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
|
||||
val progressBarWidth = remember { Animatable(0f) }
|
||||
LaunchedEffect(recordedDurationMs, finishedRecording) {
|
||||
snapshotFlow { progress.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
val startTime = when {
|
||||
finishedRecording -> progress.value
|
||||
else -> recordedDurationMs
|
||||
}
|
||||
val endTime = when {
|
||||
finishedRecording -> duration.value
|
||||
audioPlaying.value -> recordedDurationMs
|
||||
else -> MAX_VOICE_MILLIS_FOR_SENDING
|
||||
}
|
||||
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
|
||||
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
|
||||
}
|
||||
}
|
||||
Spacer(
|
||||
Modifier
|
||||
.requiredWidth(progressBarWidth.value.dp)
|
||||
.padding(top = 58.dp)
|
||||
.height(3.dp)
|
||||
.background(MaterialTheme.colors.primary)
|
||||
)
|
||||
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||
Row(
|
||||
Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(sentColor),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (!audioPlaying.value) {
|
||||
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
|
||||
} else {
|
||||
AudioPlayer.pause(audioPlaying, progress)
|
||||
}
|
||||
},
|
||||
enabled = finishedRecording) {
|
||||
Icon(
|
||||
if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
val numberInText = remember(recordedDurationMs, progress.value) {
|
||||
derivedStateOf {
|
||||
when {
|
||||
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
|
||||
finishedRecording -> progress.value / 1000
|
||||
else -> recordedDurationMs / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
durationText(numberInText.value),
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (cancelEnabled) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
AudioPlayer.stop(filePath)
|
||||
cancelVoice()
|
||||
},
|
||||
modifier = Modifier.padding(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_close),
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewComposeAudioView() {
|
||||
SimpleXTheme {
|
||||
ComposeFileView(
|
||||
"test.txt",
|
||||
cancelFile = {},
|
||||
cancelEnabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.ComposeContextItem
|
||||
import chat.simplex.app.views.chat.ComposeState
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||
|
||||
@Composable
|
||||
fun ChatItemView(
|
||||
cInfo: ChatInfo,
|
||||
cItem: ChatItem,
|
||||
composeState: MutableState<ComposeState>,
|
||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||
showMember: Boolean = false,
|
||||
useLinkPreviews: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
scrollToItem: (Long) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val sent = cItem.chatDir.sent
|
||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val revealed = remember { mutableStateOf(false) }
|
||||
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
val live = composeState.value.liveMessage != null
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
val onClick = {
|
||||
when (cItem.meta.itemStatus) {
|
||||
is CIStatus.SndErrorAuth -> {
|
||||
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
|
||||
}
|
||||
is CIStatus.SndError -> {
|
||||
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
|
||||
) {
|
||||
@Composable
|
||||
fun framedItemView() {
|
||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
||||
}
|
||||
|
||||
fun deleteMessageQuestionText(): String {
|
||||
return if (fullDeleteAllowed) {
|
||||
generalGetString(R.string.delete_message_cannot_be_undone_warning)
|
||||
} else {
|
||||
generalGetString(R.string.delete_message_mark_deleted_warning)
|
||||
}
|
||||
}
|
||||
|
||||
fun moderateMessageQuestionText(): String {
|
||||
return if (fullDeleteAllowed) {
|
||||
generalGetString(R.string.moderate_message_will_be_deleted_warning)
|
||||
} else {
|
||||
generalGetString(R.string.moderate_message_will_be_marked_warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MsgContentItemDropdownMenu() {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (cItem.meta.itemDeleted == null && !live) {
|
||||
ItemAction(stringResource(R.string.reply_verb), painterResource(R.drawable.ic_reply), onClick = {
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = {
|
||||
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
||||
when {
|
||||
filePath != null -> shareFile(context, cItem.text, filePath)
|
||||
else -> shareText(context, cItem.content.text)
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = {
|
||||
copyText(context, cItem.content.text)
|
||||
showMenu.value = false
|
||||
})
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||
val filePath = getLoadedFilePath(context, cItem.file)
|
||||
if (filePath != null) {
|
||||
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
ItemAction(stringResource(R.string.save_verb), painterResource(R.drawable.ic_download), onClick = {
|
||||
when (cItem.content.msgContent) {
|
||||
is MsgContent.MCImage -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
|
||||
saveImage(context, cItem.file)
|
||||
} else {
|
||||
writePermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
else -> {}
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
|
||||
ItemAction(stringResource(R.string.edit_verb), painterResource(R.drawable.ic_edit_filled), onClick = {
|
||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
if (cItem.meta.itemDeleted != null && revealed.value) {
|
||||
ItemAction(
|
||||
stringResource(R.string.hide_verb),
|
||||
painterResource(R.drawable.ic_visibility_off),
|
||||
onClick = {
|
||||
revealed.value = false
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
|
||||
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
|
||||
}
|
||||
if (!(live && cItem.meta.isLive)) {
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
val groupInfo = cItem.memberToModerate(cInfo)?.first
|
||||
if (groupInfo != null) {
|
||||
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkedDeletedItemDropdownMenu() {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (!cItem.isDeletedContent) {
|
||||
ItemAction(
|
||||
stringResource(R.string.reveal_verb),
|
||||
painterResource(R.drawable.ic_visibility),
|
||||
onClick = {
|
||||
revealed.value = true
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContentItem() {
|
||||
val mc = cItem.content.msgContent
|
||||
if (cItem.meta.itemDeleted != null && !revealed.value) {
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
MarkedDeletedItemDropdownMenu()
|
||||
} else if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
|
||||
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem, cInfo.timedMessagesTTL)
|
||||
MsgContentItemDropdownMenu()
|
||||
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
|
||||
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
|
||||
MsgContentItemDropdownMenu()
|
||||
} else {
|
||||
framedItemView()
|
||||
MsgContentItemDropdownMenu()
|
||||
}
|
||||
} else {
|
||||
framedItemView()
|
||||
MsgContentItemDropdownMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun DeletedItem() {
|
||||
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun CallItem(status: CICallStatus, duration: Int) {
|
||||
CICallItemView(cInfo, cItem, status, duration, acceptCall)
|
||||
}
|
||||
|
||||
when (val c = cItem.content) {
|
||||
is CIContent.SndMsgContent -> ContentItem()
|
||||
is CIContent.RcvMsgContent -> ContentItem()
|
||||
is CIContent.SndDeleted -> DeletedItem()
|
||||
is CIContent.RcvDeleted -> DeletedItem()
|
||||
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
|
||||
is CIContent.SndGroupEventContent -> CIEventView(cItem)
|
||||
is CIContent.RcvConnEventContent -> CIEventView(cItem)
|
||||
is CIContent.SndConnEventContent -> CIEventView(cItem)
|
||||
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||
is CIContent.RcvChatPreference -> {
|
||||
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
|
||||
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
|
||||
}
|
||||
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,)
|
||||
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
|
||||
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
|
||||
is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CancelFileItemAction(
|
||||
fileId: Long,
|
||||
showMenu: MutableState<Boolean>,
|
||||
cancelFile: (Long) -> Unit,
|
||||
cancelAction: CancelAction
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(cancelAction.uiActionId),
|
||||
painterResource(R.drawable.ic_close),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
cancelFileAlertDialog(fileId, cancelFile = cancelFile, cancelAction = cancelAction)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItemAction(
|
||||
cItem: ChatItem,
|
||||
showMenu: MutableState<Boolean>,
|
||||
questionText: String,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
painterResource(R.drawable.ic_delete),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModerateItemAction(
|
||||
cItem: ChatItem,
|
||||
questionText: String,
|
||||
showMenu: MutableState<Boolean>,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.moderate_verb),
|
||||
painterResource(R.drawable.ic_flag),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||
val finalColor = if (color == Color.Unspecified) {
|
||||
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||
} else color
|
||||
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = finalColor
|
||||
)
|
||||
Icon(icon, text, tint = finalColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||
val finalColor = if (color == Color.Unspecified) {
|
||||
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||
} else color
|
||||
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = finalColor
|
||||
)
|
||||
Icon(icon, text, tint = finalColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(cancelAction.alert.titleId),
|
||||
text = generalGetString(cancelAction.alert.messageId),
|
||||
confirmText = generalGetString(cancelAction.alert.confirmId),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
cancelFile(fileId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(R.string.delete_message__question),
|
||||
text = questionText,
|
||||
buttons = {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_me_only), color = MaterialTheme.colors.error) }
|
||||
if (chatItem.meta.editable) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_everybody), color = MaterialTheme.colors.error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.delete_member_message__question),
|
||||
text = questionText,
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun showMsgDeliveryErrorAlert(description: String) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.message_delivery_error_title),
|
||||
text = description,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemView() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getSampleData(
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
acceptFeature = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemViewDeletedContent() {
|
||||
SimpleXTheme {
|
||||
ChatItemView(
|
||||
ChatInfo.Direct.sampleData,
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
useLinkPreviews = true,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||
deleteMessage = { _, _ -> },
|
||||
receiveFile = {},
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
acceptCall = { _ -> },
|
||||
scrollToItem = {},
|
||||
acceptFeature = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package chat.simplex.app.views.localauth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) {
|
||||
val passcode = rememberSaveable { mutableStateOf("") }
|
||||
PasscodeView(passcode, authRequest.title ?: stringResource(R.string.la_enter_app_passcode), authRequest.reason, stringResource(R.string.submit_passcode),
|
||||
submit = {
|
||||
val r: LAResult = if (passcode.value == authRequest.password) LAResult.Success else LAResult.Error(generalGetString(R.string.incorrect_passcode))
|
||||
authRequest.completed(r)
|
||||
},
|
||||
cancel = {
|
||||
authRequest.completed(LAResult.Error(generalGetString(R.string.authentication_cancelled)))
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24.dp"
|
||||
android:height="24.dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m480,520.5 l-209,209q-9,9 -20.25,9t-20.25,-9q-9,-9 -9,-20.25t9,-20.25L440,479.5 230.5,270q-9,-8.5 -9,-19.75t9,-20.25q9,-9 20.25,-9t20.25,9l209,209.5 209,-209q9,-9 20.25,-9t20.25,9q9,9 9,20.25t-9,20.25l-209,209L730,689.5q8.5,9 8.5,20.25T730,729.5q-9,9 -20.25,9t-20.25,-9L480,520.5Z"/>
|
||||
</vector>
|
||||
@@ -1,49 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="accept_contact_button">اقبل</string>
|
||||
<string name="about_simplex_chat">عن <xliff:g id="appNameFull"> ٍSimpleX </xliff:g></string>
|
||||
<string name="a_plus_b">a + b</string>
|
||||
<string name="accept">اقبل</string>
|
||||
<string name="chat_item_ttl_week">اسبوع 1</string>
|
||||
<string name="chat_item_ttl_month">شهر 1</string>
|
||||
<string name="color_primary">لون تمييزي</string>
|
||||
<string name="chat_item_ttl_day">يوم 1</string>
|
||||
<string name="accept_feature">اقبل</string>
|
||||
<string name="about_simplex">عن SimpleX</string>
|
||||
<string name="above_then_preposition_continuation">أعلاه ، ثم:</string>
|
||||
<string name="accept_call_on_lock_screen">اقبل</string>
|
||||
<string name="delete_chat_profile_action_cannot_be_undone_warning">لا يمكن التراجع عن هذا الإجراء - سيتم فقد ملف التعريف وجهات الاتصال والرسائل والملفات الخاصة بك بشكل نهائي.</string>
|
||||
<string name="alert_message_no_group">هذه المجموعة لم تعد موجودة.</string>
|
||||
<string name="this_QR_code_is_not_a_link">رمز QR هذا ليس رابطًا!</string>
|
||||
<string name="next_generation_of_private_messaging">الجيل القادم من الرسائل الخاصة</string>
|
||||
<string name="delete_files_and_media_desc">لا يمكن التراجع عن هذا الإجراء - سيتم حذف جميع الملفات والوسائط المستلمة والمرسلة. ستبقى الصور منخفضة الدقة.</string>
|
||||
<string name="enable_automatic_deletion_message">لا يمكن التراجع عن هذا الإجراء - سيتم حذف الرسائل المرسلة والمستلمة قبل التحديد. قد تأخذ عدة دقائق.</string>
|
||||
<string name="messages_section_description">ينطبق هذا الإعداد على الرسائل الموجودة في ملف تعريف الدردشة الحالي الخاص بك</string>
|
||||
<string name="the_messaging_and_app_platform_protecting_your_privacy_and_security">منصة الرسائل والتطبيقات تحمي خصوصيتك وأمنك.</string>
|
||||
<string name="profile_is_only_shared_with_your_contacts">يتم مشاركة ملف التعريف مع جهات الاتصال الخاصة بك فقط.</string>
|
||||
<string name="member_role_will_be_changed_with_notification">سيتم تغيير الدور إلى \"%s\". سيتم إبلاغ كل فرد في المجموعة.</string>
|
||||
<string name="member_role_will_be_changed_with_invitation">سيتم تغيير الدور إلى \"%s\". سيتلقى العضو دعوة جديدة.</string>
|
||||
<string name="smp_servers_per_user">خوادم الاتصالات الجديدة لملف تعريف الدردشة الحالي الخاص بك</string>
|
||||
<string name="switch_receiving_address_desc">هذه الميزة تجريبية! ستعمل فقط إذا كان لدى العميل الآخر الإصدار 4.2 مثبتًا. يجب أن ترى الرسالة في المحادثة بمجرد اكتمال تغيير العنوان - يرجى التحقق من أنه لا يزال بإمكانك تلقي الرسائل من جهة الاتصال هذه (أو عضو المجموعة).</string>
|
||||
<string name="this_link_is_not_a_valid_connection_link">هذا الارتباط ليس ارتباط اتصال صالح!</string>
|
||||
<string name="allow_verb">يسمح</string>
|
||||
<string name="smp_servers_preset_add">أضف خوادم محددة مسبقًا</string>
|
||||
<string name="smp_servers_add_to_another_device">أضف إلى جهاز آخر</string>
|
||||
<string name="users_delete_all_chats_deleted">سيتم حذف جميع الدردشات والرسائل - لا يمكن التراجع عن هذا!</string>
|
||||
<string name="network_enable_socks_info">الوصول إلى الخوادم عبر بروكسي SOCKS على المنفذ %d؟ يجب بدء تشغيل الوكيل قبل تمكين هذا الخيار.</string>
|
||||
<string name="smp_servers_add">إضافة خادم …</string>
|
||||
<string name="network_settings">إعدادات الشبكة المتقدمة</string>
|
||||
<string name="all_group_members_will_remain_connected">سيبقى جميع أعضاء المجموعة على اتصال.</string>
|
||||
<string name="allow_disappearing_messages_only_if">السماح باختفاء الرسائل فقط إذا سمحت جهة الاتصال الخاصة بك بذلك.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">السماح بحذف الرسائل بشكل لا رجوع فيه فقط إذا سمحت لك جهة الاتصال بذلك.</string>
|
||||
<string name="group_member_role_admin">مسؤل</string>
|
||||
<string name="users_add">إضافة ملف التعريف</string>
|
||||
<string name="allow_direct_messages">السماح بإرسال رسائل مباشرة إلى الأعضاء.</string>
|
||||
<string name="accept_contact_incognito_button">قبول التخفي</string>
|
||||
<string name="button_add_welcome_message">أضف رسالة ترحيب</string>
|
||||
<string name="v4_3_improved_server_configuration_desc">أضف الخوادم عن طريق مسح رموز QR.</string>
|
||||
<string name="v4_2_group_links_desc">يمكن للمسؤولين إنشاء روابط للانضمام إلى المجموعات.</string>
|
||||
<string name="accept_connection_request__question">قبول طلب الاتصال؟</string>
|
||||
<string name="clear_chat_warning">سيتم حذف جميع الرسائل - لا يمكن التراجع عن هذا! سيتم حذف الرسائل فقط من أجلك.</string>
|
||||
<string name="callstatus_accepted">مكالمة مقبولة</string>
|
||||
</resources>
|
||||
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
@@ -18,6 +18,7 @@ struct ContentView: View {
|
||||
@Binding var userAuthorized: Bool?
|
||||
@Binding var canConnectCall: Bool
|
||||
@Binding var lastSuccessfulUnlock: TimeInterval?
|
||||
@Binding var showInitializationView: Bool
|
||||
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@@ -69,6 +70,8 @@ struct ContentView: View {
|
||||
@ViewBuilder private func contentView() -> some View {
|
||||
if prefPerformLA && userAuthorized != true {
|
||||
lockButton()
|
||||
} else if chatModel.chatDbStatus == nil && showInitializationView {
|
||||
initializationView()
|
||||
} else if let status = chatModel.chatDbStatus, status != .ok {
|
||||
DatabaseErrorView(status: status)
|
||||
} else if !chatModel.v3DBMigration.startChat {
|
||||
@@ -104,6 +107,14 @@ struct ContentView: View {
|
||||
Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") }
|
||||
}
|
||||
|
||||
private func initializationView() -> some View {
|
||||
VStack {
|
||||
ProgressView().scaleEffect(2)
|
||||
Text("Opening database…")
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func mainView() -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
||||
@@ -178,7 +189,7 @@ struct ContentView: View {
|
||||
private func justAuthenticate() {
|
||||
userAuthorized = false
|
||||
let laMode = privacyLocalAuthModeDefault.get()
|
||||
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason")) { laResult in
|
||||
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
||||
logger.debug("authenticate callback: \(String(describing: laResult))")
|
||||
switch (laResult) {
|
||||
case .success:
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
//import UIKit
|
||||
import SimpleXChat
|
||||
|
||||
let s = """
|
||||
{
|
||||
"contactConnection" : {
|
||||
"contactConnection" : {
|
||||
"viaContactUri" : false,
|
||||
"pccConnId" : 456,
|
||||
"pccAgentConnId" : "cTdjbmR4ZzVzSmhEZHdzMQ==",
|
||||
"pccConnStatus" : "new",
|
||||
"updatedAt" : "2022-04-24T11:59:23.703162Z",
|
||||
"createdAt" : "2022-04-24T11:59:23.703162Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
let s =
|
||||
"""
|
||||
{}
|
||||
"""
|
||||
//let s = "\"2022-04-24T11:59:23.703162Z\""
|
||||
//let json = getJSONDecoder()
|
||||
//let d = s.data(using: .utf8)!
|
||||
//print (try! json.decode(ChatInfo.self, from: d))
|
||||
let json = getJSONDecoder()
|
||||
let d = s.data(using: .utf8)!
|
||||
print (try! json.decode(APIResponse.self, from: d))
|
||||
|
||||
@@ -36,8 +36,9 @@ class AudioRecorder {
|
||||
try av.setActive(true)
|
||||
let settings: [String : Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 12000,
|
||||
AVEncoderBitRateKey: 12000,
|
||||
AVSampleRateKey: 16000,
|
||||
AVEncoderBitRateKey: 32000,
|
||||
AVEncoderBitRateStrategyKey: AVAudioBitRateStrategy_VariableConstrained,
|
||||
AVNumberOfChannelsKey: 1
|
||||
]
|
||||
let url = getAppFilePath(fileName)
|
||||
@@ -102,11 +103,14 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
self.onFinishPlayback = onFinishPlayback
|
||||
}
|
||||
|
||||
func start(fileName: String) {
|
||||
func start(fileName: String, at: TimeInterval?) {
|
||||
let url = getAppFilePath(fileName)
|
||||
audioPlayer = try? AVAudioPlayer(contentsOf: url)
|
||||
audioPlayer?.delegate = self
|
||||
audioPlayer?.prepareToPlay()
|
||||
if let at = at {
|
||||
audioPlayer?.currentTime = at
|
||||
}
|
||||
audioPlayer?.play()
|
||||
|
||||
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
|
||||
@@ -125,6 +129,17 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
audioPlayer?.play()
|
||||
}
|
||||
|
||||
func seek(_ to: TimeInterval) {
|
||||
if audioPlayer?.isPlaying == true {
|
||||
audioPlayer?.pause()
|
||||
audioPlayer?.currentTime = to
|
||||
audioPlayer?.play()
|
||||
} else {
|
||||
audioPlayer?.currentTime = to
|
||||
}
|
||||
self.onTimer?(to)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let player = audioPlayer {
|
||||
player.stop()
|
||||
|
||||
@@ -238,16 +238,9 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
let ci = reversedChatItems[i]
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
withAnimation {
|
||||
self.reversedChatItems[i] = cItem
|
||||
self.reversedChatItems[i].viewTimestamp = .now
|
||||
// on some occasions the confirmation of message being accepted by the server (tick)
|
||||
// arrives earlier than the response from API, and item remains without tick
|
||||
if case .sndNew = cItem.meta.itemStatus {
|
||||
self.reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
|
||||
}
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
@@ -264,7 +257,30 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
withAnimation {
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
||||
let ci = reversedChatItems[i]
|
||||
reversedChatItems[i] = cItem
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
// on some occasions the confirmation of message being accepted by the server (tick)
|
||||
// arrives earlier than the response from API, and item remains without tick
|
||||
if case .sndNew = cItem.meta.itemStatus {
|
||||
reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
|
||||
}
|
||||
}
|
||||
|
||||
private func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
||||
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
||||
}
|
||||
|
||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if cItem.isRcvNew {
|
||||
decreaseUnreadCounter(cInfo)
|
||||
@@ -277,7 +293,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// remove from current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
_ = withAnimation {
|
||||
self.reversedChatItems.remove(at: i)
|
||||
}
|
||||
@@ -357,7 +373,7 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
|
||||
if let cItem = aboveItem {
|
||||
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
markCurrentChatRead(fromIndex: i)
|
||||
_updateChat(cInfo.id) { chat in
|
||||
var unreadBelow = 0
|
||||
@@ -405,7 +421,7 @@ final class ChatModel: ObservableObject {
|
||||
// update preview
|
||||
decreaseUnreadCounter(cInfo)
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
markChatItemRead_(i)
|
||||
}
|
||||
}
|
||||
@@ -450,7 +466,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == ci.id }), i < reversedChatItems.count - 1 {
|
||||
if let i = getChatItemIndex(ci), i < reversedChatItems.count - 1 {
|
||||
return reversedChatItems[i + 1]
|
||||
} else {
|
||||
return nil
|
||||
@@ -567,6 +583,14 @@ final class Chat: ObservableObject, Identifiable {
|
||||
self.chatStats = chatStats
|
||||
}
|
||||
|
||||
func copy(chatInfo: ChatInfo? = nil, chatItems: [ChatItem]? = nil, chatStats: ChatStats? = nil) -> Chat {
|
||||
Chat(
|
||||
chatInfo: chatInfo ?? self.chatInfo,
|
||||
chatItems: chatItems ?? self.chatItems,
|
||||
chatStats: chatStats ?? self.chatStats
|
||||
)
|
||||
}
|
||||
|
||||
var userCanSend: Bool {
|
||||
switch chatInfo {
|
||||
case .direct: return true
|
||||
|
||||
@@ -42,7 +42,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
changeActiveUser(userId, viewPwd: nil)
|
||||
}
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact,
|
||||
let chatId = content.userInfo["chatId"] as? String {
|
||||
let chatId = content.userInfo["chatId"] as? String {
|
||||
if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
|
||||
Task { await acceptContactRequest(contactRequest) }
|
||||
} else {
|
||||
@@ -107,8 +107,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
// in another chat
|
||||
return recent ? [.banner, .list] : [.sound, .banner, .list]
|
||||
}
|
||||
// this notification is deliverd from the notifications server
|
||||
// when the app is in foreground it does not need to be shown
|
||||
// this notification is deliverd from the notifications server
|
||||
// when the app is in foreground it does not need to be shown
|
||||
case ntfCategoryCheckMessage: return []
|
||||
case ntfCategoryCallInvitation: return []
|
||||
default: return [.sound, .banner, .list]
|
||||
@@ -247,8 +247,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func removeNotifications(_ ids : [String]){
|
||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
|
||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
|
||||
func removeAllNotifications() async {
|
||||
let nc = UNUserNotificationCenter.current()
|
||||
let settings = await nc.notificationSettings()
|
||||
if settings.authorizationStatus == .authorized {
|
||||
nc.removeAllPendingNotificationRequests()
|
||||
nc.removeAllDeliveredNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
|
||||
|
||||
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) -> ChatResponse {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
let start = Date.now
|
||||
let resp = bgTask
|
||||
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd) }
|
||||
: sendSimpleXCmd(cmd)
|
||||
@@ -94,7 +95,7 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
|
||||
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.addTerminalItem(.cmd(.now, cmd.obfuscated))
|
||||
ChatModel.shared.addTerminalItem(.cmd(start, cmd.obfuscated))
|
||||
ChatModel.shared.addTerminalItem(.resp(.now, resp))
|
||||
}
|
||||
return resp
|
||||
@@ -125,8 +126,8 @@ func apiGetActiveUser() throws -> User? {
|
||||
}
|
||||
}
|
||||
|
||||
func apiCreateActiveUser(_ p: Profile) throws -> User {
|
||||
let r = chatSendCmdSync(.createActiveUser(profile: p))
|
||||
func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false) throws -> User {
|
||||
let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp))
|
||||
if case let .activeUser(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
@@ -243,8 +244,10 @@ func apiExportArchive(config: ArchiveConfig) async throws {
|
||||
try await sendCommandOkResp(.apiExportArchive(config: config))
|
||||
}
|
||||
|
||||
func apiImportArchive(config: ArchiveConfig) async throws {
|
||||
try await sendCommandOkResp(.apiImportArchive(config: config))
|
||||
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
||||
let r = await chatSendCmd(.apiImportArchive(config: config))
|
||||
if case let .archiveImported(archiveErrors) = r { return archiveErrors }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteStorage() async throws {
|
||||
@@ -295,9 +298,15 @@ func loadChat(chat: Chat, search: String = "") {
|
||||
}
|
||||
}
|
||||
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? {
|
||||
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
|
||||
let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId))
|
||||
if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
|
||||
let chatModel = ChatModel.shared
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live)
|
||||
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!
|
||||
@@ -308,7 +317,9 @@ func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int6
|
||||
chatModel.messageDelivery[cItem.id] = endTask
|
||||
return cItem
|
||||
}
|
||||
if !networkErrorAlert(r) {
|
||||
if let networkErrorAlert = networkErrorAlert(r) {
|
||||
AlertManager.shared.showAlert(networkErrorAlert)
|
||||
} else {
|
||||
sendMessageErrorAlert(r)
|
||||
}
|
||||
endTask()
|
||||
@@ -337,6 +348,12 @@ func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
|
||||
let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
|
||||
if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
|
||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
|
||||
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
||||
@@ -463,12 +480,28 @@ func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (Con
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSwitchContact(contactId: Int64) async throws {
|
||||
try await sendCommandOkResp(.apiSwitchContact(contactId: contactId))
|
||||
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
|
||||
let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId))
|
||||
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws {
|
||||
try await sendCommandOkResp(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
|
||||
func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats {
|
||||
let r = chatSendCmdSync(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberSwitchStarted(_, _, _, connectionStats) = r { return connectionStats }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiAbortSwitchContact(_ contactId: Int64) throws -> ConnectionStats {
|
||||
let r = chatSendCmdSync(.apiAbortSwitchContact(contactId: contactId))
|
||||
if case let .contactSwitchAborted(_, _, connectionStats) = r { return connectionStats }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiAbortSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats {
|
||||
let r = chatSendCmdSync(.apiAbortSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberSwitchAborted(_, _, _, connectionStats) = r { return connectionStats }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
|
||||
@@ -504,59 +537,70 @@ func apiAddContact() async -> String? {
|
||||
}
|
||||
let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false)
|
||||
if case let .invitation(_, connReqInvitation) = r { return connReqInvitation }
|
||||
connectionErrorAlert(r)
|
||||
AlertManager.shared.showAlert(connectionErrorAlert(r))
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiConnect(connReq: String) async -> ConnReqType? {
|
||||
let (connReqType, alert) = await apiConnect_(connReq: connReq)
|
||||
if let alert = alert {
|
||||
AlertManager.shared.showAlert(alert)
|
||||
return nil
|
||||
} else {
|
||||
return connReqType
|
||||
}
|
||||
}
|
||||
|
||||
func apiConnect_(connReq: String) async -> (ConnReqType?, Alert?) {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiConnect: no current user")
|
||||
return nil
|
||||
return (nil, nil)
|
||||
}
|
||||
let r = await chatSendCmd(.apiConnect(userId: userId, connReq: connReq))
|
||||
let am = AlertManager.shared
|
||||
switch r {
|
||||
case .sentConfirmation: return .invitation
|
||||
case .sentInvitation: return .contact
|
||||
case .sentConfirmation: return (.invitation, nil)
|
||||
case .sentInvitation: return (.contact, nil)
|
||||
case let .contactAlreadyExists(_, contact):
|
||||
let m = ChatModel.shared
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
await MainActor.run { m.chatId = c.id }
|
||||
}
|
||||
am.showAlertMsg(
|
||||
let alert = mkAlert(
|
||||
title: "Contact already exists",
|
||||
message: "You are already connected to \(contact.displayName)."
|
||||
)
|
||||
return nil
|
||||
return (nil, alert)
|
||||
case .chatCmdError(_, .error(.invalidConnReq)):
|
||||
am.showAlertMsg(
|
||||
let alert = mkAlert(
|
||||
title: "Invalid connection link",
|
||||
message: "Please check that you used the correct link or ask your contact to send you another one."
|
||||
)
|
||||
return nil
|
||||
return (nil, alert)
|
||||
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
|
||||
am.showAlertMsg(
|
||||
let alert = mkAlert(
|
||||
title: "Connection error (AUTH)",
|
||||
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
|
||||
)
|
||||
return nil
|
||||
return (nil, alert)
|
||||
case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))):
|
||||
if internalErr == "SEUniqueID" {
|
||||
am.showAlertMsg(
|
||||
let alert = mkAlert(
|
||||
title: "Already connected?",
|
||||
message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(responseError(r)))."
|
||||
)
|
||||
return nil
|
||||
return (nil, alert)
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
connectionErrorAlert(r)
|
||||
return nil
|
||||
let alert = connectionErrorAlert(r)
|
||||
return (nil, alert)
|
||||
}
|
||||
|
||||
private func connectionErrorAlert(_ r: ChatResponse) {
|
||||
if !networkErrorAlert(r) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
|
||||
if let networkErrorAlert = networkErrorAlert(r) {
|
||||
return networkErrorAlert
|
||||
} else {
|
||||
return mkAlert(
|
||||
title: "Connection error",
|
||||
message: "Error: \(String(describing: r))"
|
||||
)
|
||||
@@ -698,7 +742,9 @@ func apiAcceptContactRequest(contactReqId: Int64) async -> Contact? {
|
||||
title: "Connection error (AUTH)",
|
||||
message: "Sender may have deleted the connection request."
|
||||
)
|
||||
} else if !networkErrorAlert(r) {
|
||||
} else if let networkErrorAlert = networkErrorAlert(r) {
|
||||
am.showAlert(networkErrorAlert)
|
||||
} else {
|
||||
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
|
||||
am.showAlertMsg(
|
||||
title: "Error accepting contact request",
|
||||
@@ -737,7 +783,9 @@ func apiReceiveFile(fileId: Int64, inline: Bool? = nil) async -> AChatItem? {
|
||||
title: "Cannot receive file",
|
||||
message: "Sender cancelled file transfer."
|
||||
)
|
||||
} else if !networkErrorAlert(r) {
|
||||
} else if let networkErrorAlert = networkErrorAlert(r) {
|
||||
am.showAlert(networkErrorAlert)
|
||||
} else {
|
||||
logger.error("apiReceiveFile error: \(String(describing: r))")
|
||||
switch r {
|
||||
case .chatCmdError(_, .error(.fileAlreadyReceiving)):
|
||||
@@ -770,23 +818,20 @@ func apiCancelFile(fileId: Int64) async -> AChatItem? {
|
||||
}
|
||||
}
|
||||
|
||||
func networkErrorAlert(_ r: ChatResponse) -> Bool {
|
||||
let am = AlertManager.shared
|
||||
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||
switch r {
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||
am.showAlertMsg(
|
||||
return mkAlert(
|
||||
title: "Connection timeout",
|
||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||
)
|
||||
return true
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
|
||||
am.showAlertMsg(
|
||||
return mkAlert(
|
||||
title: "Connection error",
|
||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||
)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,7 +900,9 @@ func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
|
||||
let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId)
|
||||
let cInfo = chat.chatInfo
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
|
||||
await MainActor.run { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
|
||||
await MainActor.run {
|
||||
withAnimation { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
|
||||
}
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
@@ -869,7 +916,9 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
try await apiChatUnread(type: cInfo.chatType, id: cInfo.apiId, unreadChat: unreadChat)
|
||||
await MainActor.run { ChatModel.shared.markChatUnread(cInfo, unreadChat: unreadChat) }
|
||||
await MainActor.run {
|
||||
withAnimation { ChatModel.shared.markChatUnread(cInfo, unreadChat: unreadChat) }
|
||||
}
|
||||
} catch {
|
||||
logger.error("markChatUnread apiChatUnread error: \(responseError(error))")
|
||||
}
|
||||
@@ -1025,6 +1074,7 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
if m.currentUser == nil {
|
||||
onboardingStageDefault.set(.step1_SimpleXInfo)
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
} else if start {
|
||||
try startChat(refreshInvitations: refreshInvitations)
|
||||
@@ -1050,9 +1100,10 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
registerToken(token: token)
|
||||
}
|
||||
withAnimation {
|
||||
m.onboardingStage = m.onboardingStage == .step2_CreateProfile && m.users.count == 1
|
||||
let savedOnboardingStage = onboardingStageDefault.get()
|
||||
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
|
||||
? .step3_CreateSimpleXAddress
|
||||
: .onboardingComplete
|
||||
: savedOnboardingStage
|
||||
}
|
||||
}
|
||||
ChatReceiver.shared.start()
|
||||
@@ -1232,15 +1283,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
|
||||
m.increaseUnreadCounter(user: user)
|
||||
}
|
||||
if let file = cItem.file,
|
||||
let mc = cItem.content.msgContent,
|
||||
file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV {
|
||||
let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
||||
if (mc.isImage && acceptImages)
|
||||
|| (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) {
|
||||
Task {
|
||||
await receiveFile(user: user, fileId: file.fileId) // TODO check inlineFileMode != IFMSent
|
||||
}
|
||||
if let file = cItem.autoReceiveFile() {
|
||||
Task {
|
||||
await receiveFile(user: user, fileId: file.fileId)
|
||||
}
|
||||
}
|
||||
if cItem.showNotification {
|
||||
@@ -1262,6 +1307,10 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
case let .chatItemUpdated(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .chatItemReaction(user, _, r):
|
||||
if active(user) {
|
||||
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
|
||||
}
|
||||
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
|
||||
if !active(user) {
|
||||
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
|
||||
@@ -1316,10 +1365,13 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
if active(user) {
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
case let .connectedToGroupMember(user, groupInfo, member):
|
||||
case let .connectedToGroupMember(user, groupInfo, member, memberContact):
|
||||
if active(user) {
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
if let contact = memberContact {
|
||||
m.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
case let .groupUpdated(user, toGroup):
|
||||
if active(user) {
|
||||
m.updateGroup(toGroup)
|
||||
|
||||
@@ -23,6 +23,7 @@ struct SimpleXApp: App {
|
||||
@State private var enteredBackground: TimeInterval? = nil
|
||||
@State private var canConnectCall = false
|
||||
@State private var lastSuccessfulUnlock: TimeInterval? = nil
|
||||
@State private var showInitializationView = false
|
||||
|
||||
init() {
|
||||
hs_init(0, nil)
|
||||
@@ -36,14 +37,23 @@ struct SimpleXApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
return WindowGroup {
|
||||
ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall, lastSuccessfulUnlock: $lastSuccessfulUnlock)
|
||||
ContentView(
|
||||
doAuthenticate: $doAuthenticate,
|
||||
userAuthorized: $userAuthorized,
|
||||
canConnectCall: $canConnectCall,
|
||||
lastSuccessfulUnlock: $lastSuccessfulUnlock,
|
||||
showInitializationView: $showInitializationView
|
||||
)
|
||||
.environmentObject(chatModel)
|
||||
.onOpenURL { url in
|
||||
logger.debug("ContentView.onOpenURL: \(url)")
|
||||
chatModel.appOpenUrl = url
|
||||
}
|
||||
.onAppear() {
|
||||
initChatAndMigrate()
|
||||
showInitializationView = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
initChatAndMigrate()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||
|
||||
@@ -13,6 +13,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
RTCInitializeSSL()
|
||||
let videoEncoderFactory = RTCDefaultVideoEncoderFactory()
|
||||
let videoDecoderFactory = RTCDefaultVideoDecoderFactory()
|
||||
videoEncoderFactory.preferredCodec = RTCVideoCodecInfo(name: kRTCVp8CodecName)
|
||||
return RTCPeerConnectionFactory(encoderFactory: videoEncoderFactory, decoderFactory: videoDecoderFactory)
|
||||
}()
|
||||
private static let ivTagBytes: Int = 28
|
||||
@@ -301,6 +302,17 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
|
||||
func startCaptureLocalVideo(_ activeCall: Call) {
|
||||
#if targetEnvironment(simulator)
|
||||
guard
|
||||
let capturer = activeCall.localCamera as? RTCFileVideoCapturer
|
||||
else {
|
||||
logger.error("Unable to work with a file capturer")
|
||||
return
|
||||
}
|
||||
capturer.stopCapture()
|
||||
// Drag video file named `video.mp4` to `sounds` directory in the project from any other path in filesystem
|
||||
capturer.startCapturing(fromFileNamed: "sounds/video.mp4")
|
||||
#else
|
||||
guard
|
||||
let capturer = activeCall.localCamera as? RTCCameraVideoCapturer,
|
||||
let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == activeCall.device })
|
||||
@@ -328,6 +340,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
capturer.startCapture(with: camera,
|
||||
format: format,
|
||||
fps: Int(min(24, fps.maxFrameRate)))
|
||||
#endif
|
||||
}
|
||||
|
||||
private func createAudioSender(_ connection: RTCPeerConnection) {
|
||||
|
||||
@@ -36,9 +36,8 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]?) -> some View {
|
||||
if let servers = servers,
|
||||
servers.count > 0 {
|
||||
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]) -> some View {
|
||||
if servers.count > 0 {
|
||||
HStack {
|
||||
Text(title).frame(width: 120, alignment: .leading)
|
||||
Button(serverHost(servers[0])) {
|
||||
@@ -76,6 +75,7 @@ struct ChatInfoView: View {
|
||||
case clearChatAlert
|
||||
case networkStatusAlert
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
@@ -84,6 +84,7 @@ struct ChatInfoView: View {
|
||||
case .clearChatAlert: return "clearChatAlert"
|
||||
case .networkStatusAlert: return "networkStatusAlert"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
@@ -136,12 +137,19 @@ struct ChatInfoView: View {
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
}
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
smpServers("Receiving via", connStats.rcvServers)
|
||||
smpServers("Sending via", connStats.sndServers)
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil })
|
||||
if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch })
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +174,7 @@ struct ChatInfoView: View {
|
||||
case .clearChatAlert: return clearChatAlert()
|
||||
case .networkStatusAlert: return networkStatusAlert()
|
||||
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
@@ -359,7 +368,8 @@ struct ChatInfoView: View {
|
||||
private func switchContactAddress() {
|
||||
Task {
|
||||
do {
|
||||
try await apiSwitchContact(contactId: contact.apiId)
|
||||
let stats = try apiSwitchContact(contactId: contact.apiId)
|
||||
connectionStats = stats
|
||||
} catch let error {
|
||||
logger.error("switchContactAddress apiSwitchContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -369,13 +379,37 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func abortSwitchContactAddress() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiAbortSwitchContact(contact.apiId)
|
||||
connectionStats = stats
|
||||
} catch let error {
|
||||
logger.error("abortSwitchContactAddress apiAbortSwitchContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error aborting address change")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
||||
Alert(
|
||||
title: Text("Change receiving address?"),
|
||||
message: Text("This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member)."),
|
||||
primaryButton: .destructive(Text("Change"), action: switchAddress),
|
||||
message: Text("Receiving address will be changed to a different server. Address change will complete after sender comes online."),
|
||||
primaryButton: .default(Text("Change"), action: switchAddress),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func abortSwitchAddressAlert(_ abortSwitchAddress: @escaping () -> Void) -> Alert {
|
||||
Alert(
|
||||
title: Text("Abort changing address?"),
|
||||
message: Text("Address change will be aborted. Old receiving address will be used."),
|
||||
primaryButton: .destructive(Text("Abort"), action: abortSwitchAddress),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparen
|
||||
r = r + statusIconText("timer", color).font(.caption2)
|
||||
let ttl = meta.itemTimed?.ttl
|
||||
if ttl != chatTTL {
|
||||
r = r + Text(TimedMessagesPreference.shortTtlText(ttl)).foregroundColor(color)
|
||||
r = r + Text(shortTimeText(ttl)).foregroundColor(color)
|
||||
}
|
||||
r = r + Text(" ")
|
||||
}
|
||||
|
||||
@@ -251,7 +251,6 @@ struct CIVideoView: View {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user, file.fileId)
|
||||
}
|
||||
// TODO image accepted alert?
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,20 @@ struct CIVoiceView: View {
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State var playbackTime: TimeInterval?
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
@Binding var allowMenu: Bool
|
||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if chatItem.chatDir.sent {
|
||||
VStack (alignment: .trailing, spacing: 6) {
|
||||
HStack {
|
||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||
playbackSlider()
|
||||
}
|
||||
playerTime()
|
||||
player()
|
||||
}
|
||||
@@ -32,13 +38,16 @@ struct CIVoiceView: View {
|
||||
HStack {
|
||||
player()
|
||||
playerTime()
|
||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||
playbackSlider()
|
||||
}
|
||||
}
|
||||
.frame(alignment: .leading)
|
||||
metaView().padding(.leading, -6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding([.top, .horizontal], 4)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
@@ -48,8 +57,11 @@ struct CIVoiceView: View {
|
||||
recordingFile: recordingFile,
|
||||
recordingTime: TimeInterval(duration),
|
||||
showBackground: true,
|
||||
seek: $seek,
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
playbackTime: $playbackTime,
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,6 +73,22 @@ struct CIVoiceView: View {
|
||||
)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
private func playbackSlider() -> some View {
|
||||
ComposeVoiceView.SliderBar(
|
||||
length: TimeInterval(duration),
|
||||
progress: $playbackTime,
|
||||
seek: {
|
||||
let time = max(0.0001, $0)
|
||||
seek(time)
|
||||
playbackTime = time
|
||||
})
|
||||
.onChange(of: .playing == playbackState || (playbackTime ?? 0) > 0) { show in
|
||||
if !show {
|
||||
allowMenu = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func metaView() -> some View {
|
||||
CIMetaView(chatItem: chatItem)
|
||||
@@ -95,10 +123,11 @@ struct VoiceMessagePlayer: View {
|
||||
var recordingTime: TimeInterval
|
||||
var showBackground: Bool
|
||||
|
||||
@State private var audioPlayer: AudioPlayer?
|
||||
@Binding var seek: (TimeInterval) -> Void
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
@State private var startingPlayback: Bool = false
|
||||
@Binding var allowMenu: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -109,7 +138,7 @@ struct VoiceMessagePlayer: View {
|
||||
case .sndComplete: playbackButton()
|
||||
case .sndCancelled: playbackButton()
|
||||
case .sndError: playbackButton()
|
||||
case .rcvInvitation: loadingIcon()
|
||||
case .rcvInvitation: downloadButton(recordingFile)
|
||||
case .rcvAccepted: loadingIcon()
|
||||
case .rcvTransfer: loadingIcon()
|
||||
case .rcvComplete: playbackButton()
|
||||
@@ -120,18 +149,24 @@ struct VoiceMessagePlayer: View {
|
||||
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
audioPlayer?.stop()
|
||||
.onAppear {
|
||||
seek = { to in audioPlayer?.seek(to) }
|
||||
audioPlayer?.onTimer = { playbackTime = $0 }
|
||||
audioPlayer?.onFinishPlayback = {
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
||||
if !startingPlayback {
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { it in
|
||||
if let recordingFileName = getLoadedFileName(recordingFile), chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
} else {
|
||||
startingPlayback = false
|
||||
}
|
||||
}
|
||||
.onChange(of: playbackState) { state in
|
||||
allowMenu = state == .paused || state == .noPlayback
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func playbackButton() -> some View {
|
||||
@@ -179,6 +214,18 @@ struct VoiceMessagePlayer: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadButton(_ recordingFile: CIFile) -> some View {
|
||||
Button {
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProgressCircle: View {
|
||||
var length: TimeInterval
|
||||
@Binding var progress: TimeInterval?
|
||||
@@ -204,7 +251,6 @@ struct VoiceMessagePlayer: View {
|
||||
}
|
||||
|
||||
private func startPlayback(_ recordingFileName: String) {
|
||||
startingPlayback = true
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
@@ -213,8 +259,7 @@ struct VoiceMessagePlayer: View {
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileName: recordingFileName)
|
||||
playbackTime = TimeInterval(0)
|
||||
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
|
||||
playbackState = .playing
|
||||
}
|
||||
}
|
||||
@@ -240,13 +285,15 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||
duration: 30,
|
||||
playbackState: .playing,
|
||||
playbackTime: TimeInterval(20)
|
||||
audioPlayer: .constant(nil),
|
||||
playbackState: .constant(.playing),
|
||||
playbackTime: .constant(TimeInterval(20)),
|
||||
allowMenu: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
|
||||
@@ -15,9 +15,15 @@ struct FramedCIVoiceView: View {
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State var playbackTime: TimeInterval?
|
||||
|
||||
|
||||
@Binding var allowMenu: Bool
|
||||
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
|
||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VoiceMessagePlayer(
|
||||
@@ -25,8 +31,11 @@ struct FramedCIVoiceView: View {
|
||||
recordingFile: recordingFile,
|
||||
recordingTime: TimeInterval(duration),
|
||||
showBackground: false,
|
||||
seek: $seek,
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
playbackTime: $playbackTime,
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
VoiceMessagePlayerTime(
|
||||
recordingTime: TimeInterval(duration),
|
||||
@@ -35,12 +44,31 @@ struct FramedCIVoiceView: View {
|
||||
)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 50, alignment: .leading)
|
||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||
playbackSlider()
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 6)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.bottom, chatItem.content.text.isEmpty ? 10 : 0)
|
||||
}
|
||||
|
||||
private func playbackSlider() -> some View {
|
||||
ComposeVoiceView.SliderBar(
|
||||
length: TimeInterval(duration),
|
||||
progress: $playbackTime,
|
||||
seek: {
|
||||
let time = max(0.0001, $0)
|
||||
seek(time)
|
||||
playbackTime = time
|
||||
})
|
||||
.onChange(of: .playing == playbackState || (playbackTime ?? 0) > 0) { show in
|
||||
if !show {
|
||||
allowMenu = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ struct MarkedDeletedItemView: View {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).font(.caption).fontWeight(.medium) + Text(": ").font(.caption)
|
||||
}
|
||||
if case let .moderated(byGroupMember) = chatItem.meta.itemDeleted {
|
||||
if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted {
|
||||
markedDeletedText("moderated by \(byGroupMember.chatViewName)")
|
||||
} else {
|
||||
markedDeletedText("marked deleted")
|
||||
@@ -46,7 +46,7 @@ struct MarkedDeletedItemView: View {
|
||||
struct MarkedDeletedItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted))
|
||||
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now)))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 200))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// ChatItemInfoView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 09.05.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChatItemInfoView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var ci: ChatItem
|
||||
@Binding var chatItemInfo: ChatItemInfo?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
itemInfoView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button { showShareSheet(items: [itemInfoShareText()]) } label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
ci.chatDir.sent
|
||||
? NSLocalizedString("Sent message", comment: "message info title")
|
||||
: NSLocalizedString("Received message", comment: "message info title")
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemInfoView() -> some View {
|
||||
let meta = ci.meta
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(title)
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
infoRow("Sent at", localTimestamp(meta.itemTs))
|
||||
if !ci.chatDir.sent {
|
||||
infoRow("Received at", localTimestamp(meta.createdAt))
|
||||
}
|
||||
switch (meta.itemDeleted) {
|
||||
case let .deleted(deletedTs):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Deleted at", localTimestamp(deletedTs))
|
||||
}
|
||||
case let .moderated(deletedTs, _):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Moderated at", localTimestamp(deletedTs))
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
if let deleteAt = meta.itemTimed?.deleteAt {
|
||||
infoRow("Disappears at", localTimestamp(deleteAt))
|
||||
}
|
||||
if developerTools {
|
||||
infoRow("Database ID", "\(meta.itemId)")
|
||||
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
||||
}
|
||||
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
Divider().padding(.vertical)
|
||||
|
||||
Text("History")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 4)
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(Array(chatItemInfo.itemVersions.enumerated()), id: \.element.chatItemVersionId) { index, itemVersion in
|
||||
itemVersionView(itemVersion, maxWidth, current: index == 0 && ci.meta.itemDeleted == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
versionText(itemVersion)
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(ci, colorScheme))
|
||||
.cornerRadius(18)
|
||||
.contextMenu {
|
||||
if itemVersion.msgContent.text != "" {
|
||||
Button {
|
||||
showShareSheet(items: [itemVersion.msgContent.text])
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Button {
|
||||
UIPasteboard.general.string = itemVersion.msgContent.text
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
let ts = localTimestamp(itemVersion.itemVersionTs)
|
||||
(current ? Text("\(ts) (current)") : Text(ts))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder private func versionText(_ itemVersion: ChatItemVersion) -> some View {
|
||||
if itemVersion.msgContent.text != "" {
|
||||
messageText(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||
} else {
|
||||
Text("no text")
|
||||
.italic()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func itemInfoShareText() -> String {
|
||||
let meta = ci.meta
|
||||
var shareText: [String] = [title, ""]
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Sent at: %@", comment: "copied message info"), localTimestamp(meta.itemTs))]
|
||||
if !ci.chatDir.sent {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
|
||||
}
|
||||
switch (ci.meta.itemDeleted) {
|
||||
case let .deleted(deletedTs):
|
||||
if let deletedTs = deletedTs {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Deleted at: %@", comment: "copied message info"), localTimestamp(deletedTs))]
|
||||
}
|
||||
case let .moderated(deletedTs, _):
|
||||
if let deletedTs = deletedTs {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Moderated at: %@", comment: "copied message info"), localTimestamp(deletedTs))]
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
if let deleteAt = meta.itemTimed?.deleteAt {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Disappears at: %@", comment: "copied message info"), localTimestamp(deleteAt))]
|
||||
}
|
||||
if developerTools {
|
||||
shareText += [
|
||||
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
|
||||
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt))
|
||||
]
|
||||
}
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
shareText += ["", NSLocalizedString("History", comment: "copied message info")]
|
||||
for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() {
|
||||
let t = itemVersion.msgContent.text
|
||||
shareText += [
|
||||
"",
|
||||
String.localizedStringWithFormat(
|
||||
index == 0 && ci.meta.itemDeleted == nil
|
||||
? NSLocalizedString("%@ (current):", comment: "copied message info")
|
||||
: NSLocalizedString("%@:", comment: "copied message info"),
|
||||
localTimestamp(itemVersion.itemVersionTs)
|
||||
),
|
||||
t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")
|
||||
]
|
||||
}
|
||||
}
|
||||
return shareText.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func localTimestamp(_ date: Date) -> String {
|
||||
let localDateFormatter = DateFormatter()
|
||||
localDateFormatter.dateStyle = .medium
|
||||
localDateFormatter.timeStyle = .medium
|
||||
return localDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
struct ChatItemInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), chatItemInfo: Binding.constant(nil))
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,22 @@ struct ChatItemView: View {
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@State var scrollProxy: ScrollViewProxy? = nil
|
||||
@Binding var revealed: Bool
|
||||
@Binding var allowMenu: Bool
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding<Bool>, allowMenu: Binding<Bool> = .constant(false), audioPlayer: Binding<AudioPlayer?> = .constant(nil), playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback), playbackTime: Binding<TimeInterval?> = .constant(nil)) {
|
||||
self.chatInfo = chatInfo
|
||||
self.chatItem = chatItem
|
||||
self.showMember = showMember
|
||||
self.maxWidth = maxWidth
|
||||
_scrollProxy = .init(initialValue: scrollProxy)
|
||||
_revealed = revealed
|
||||
_allowMenu = allowMenu
|
||||
_audioPlayer = audioPlayer
|
||||
_playbackState = playbackState
|
||||
_playbackTime = playbackTime
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let ci = chatItem
|
||||
@@ -25,7 +41,7 @@ struct ChatItemView: View {
|
||||
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)
|
||||
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
|
||||
} else {
|
||||
@@ -37,7 +53,7 @@ struct ChatItemView: View {
|
||||
}
|
||||
|
||||
private func framedItemView() -> some View {
|
||||
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
|
||||
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +125,7 @@ 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), 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))
|
||||
}
|
||||
@@ -126,7 +142,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted),
|
||||
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
@@ -148,7 +164,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted),
|
||||
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
@@ -159,7 +175,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted),
|
||||
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
@@ -170,7 +186,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted),
|
||||
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
content: ciFeatureContent,
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
|
||||
@@ -193,7 +193,7 @@ struct ChatView: View {
|
||||
.focused($searchFocussed)
|
||||
.foregroundColor(.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
@@ -204,7 +204,7 @@ struct ChatView: View {
|
||||
.foregroundColor(.secondary)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(10.0)
|
||||
|
||||
|
||||
Button ("Cancel") {
|
||||
searchText = ""
|
||||
searchMode = false
|
||||
@@ -219,17 +219,25 @@ struct ChatView: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func voiceWithoutFrame(_ ci: ChatItem) -> Bool {
|
||||
ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil
|
||||
}
|
||||
|
||||
private func chatItemsList() -> some View {
|
||||
let cInfo = chat.chatInfo
|
||||
return GeometryReader { g in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
let maxWidth =
|
||||
cInfo.chatType == .group
|
||||
? (g.size.width - 28) * 0.84 - 42
|
||||
: (g.size.width - 32) * 0.84
|
||||
LazyVStack(spacing: 5) {
|
||||
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
|
||||
let voiceNoFrame = voiceWithoutFrame(ci)
|
||||
let maxWidth = cInfo.chatType == .group
|
||||
? voiceNoFrame
|
||||
? (g.size.width - 28) - 42
|
||||
: (g.size.width - 28) * 0.84 - 42
|
||||
: voiceNoFrame
|
||||
? (g.size.width - 32)
|
||||
: (g.size.width - 32) * 0.84
|
||||
chatItemView(ci, maxWidth)
|
||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||
.onAppear {
|
||||
@@ -435,6 +443,7 @@ struct ChatView: View {
|
||||
|
||||
private struct ChatItemWithMenu: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var ci: ChatItem
|
||||
var showMember: Bool = false
|
||||
var maxWidth: CGFloat
|
||||
@@ -445,16 +454,30 @@ struct ChatView: View {
|
||||
@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?
|
||||
|
||||
var body: some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
let uiMenu: Binding<UIMenu> = Binding(
|
||||
get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) },
|
||||
set: { _ in }
|
||||
)
|
||||
|
||||
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed)
|
||||
.uiKitContextMenu(menu: uiMenu)
|
||||
|
||||
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)
|
||||
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
|
||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||
chatItemReactions()
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessage(.cidmInternal)
|
||||
@@ -467,11 +490,69 @@ struct ChatView: View {
|
||||
}
|
||||
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||
.onDisappear {
|
||||
if ci.content.msgContent?.isVoice == true {
|
||||
allowMenu = true
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
|
||||
chatItemInfo = nil
|
||||
}) {
|
||||
ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private func menu(live: Bool) -> [UIAction] {
|
||||
var menu: [UIAction] = []
|
||||
|
||||
private func chatItemReactions() -> some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(ci.reactions, id: \.reaction) { r in
|
||||
let v = HStack(spacing: 4) {
|
||||
switch r.reaction {
|
||||
case let .emoji(emoji): Text(emoji.rawValue).font(.caption)
|
||||
case .unknown: EmptyView()
|
||||
}
|
||||
if r.totalReacted > 1 {
|
||||
Text("\(r.totalReacted)")
|
||||
.font(.caption)
|
||||
.fontWeight(r.userReacted ? .bold : .light)
|
||||
.foregroundColor(r.userReacted ? .accentColor : .secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
|
||||
v.onTapGesture {
|
||||
setReaction(add: !r.userReacted, reaction: r.reaction)
|
||||
}
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func menu(live: Bool) -> [UIMenuElement] {
|
||||
var menu: [UIMenuElement] = []
|
||||
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
|
||||
let rs = allReactions()
|
||||
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
|
||||
rs.count > 0 {
|
||||
var rm: UIMenu
|
||||
if #available(iOS 16, *) {
|
||||
var children: [UIMenuElement] = Array(rs.prefix(topReactionsCount(rs)))
|
||||
if let sm = reactionUIMenu(rs) {
|
||||
children.append(sm)
|
||||
}
|
||||
rm = UIMenu(title: "", options: .displayInline, children: children)
|
||||
rm.preferredElementSize = .small
|
||||
} else {
|
||||
rm = reactionUIMenuPreiOS16(rs)
|
||||
}
|
||||
menu.append(rm)
|
||||
}
|
||||
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
|
||||
menu.append(replyUIAction())
|
||||
}
|
||||
@@ -491,6 +572,7 @@ struct ChatView: View {
|
||||
if ci.meta.editable && !mc.isVoice && !live {
|
||||
menu.append(editAction())
|
||||
}
|
||||
menu.append(viewInfoUIAction())
|
||||
if revealed {
|
||||
menu.append(hideUIAction())
|
||||
}
|
||||
@@ -509,6 +591,7 @@ struct ChatView: View {
|
||||
if !ci.isDeletedContent {
|
||||
menu.append(revealUIAction())
|
||||
}
|
||||
menu.append(viewInfoUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
} else if ci.isDeletedContent {
|
||||
menu.append(deleteUIAction())
|
||||
@@ -530,7 +613,59 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func reactionUIMenuPreiOS16(_ rs: [UIAction]) -> UIMenu {
|
||||
UIMenu(
|
||||
title: NSLocalizedString("React...", comment: "chat item menu"),
|
||||
image: UIImage(systemName: "face.smiling"),
|
||||
children: rs
|
||||
)
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
private func reactionUIMenu(_ rs: [UIAction]) -> UIMenu? {
|
||||
var children = rs
|
||||
children.removeFirst(min(rs.count, topReactionsCount(rs)))
|
||||
if children.count == 0 { return nil }
|
||||
return UIMenu(
|
||||
title: "",
|
||||
image: UIImage(systemName: "ellipsis"),
|
||||
children: children
|
||||
)
|
||||
}
|
||||
|
||||
private func allReactions() -> [UIAction] {
|
||||
MsgReaction.values.compactMap { r in
|
||||
ci.reactions.contains(where: { $0.userReacted && $0.reaction == r })
|
||||
? nil
|
||||
: UIAction(title: r.text) { _ in setReaction(add: true, reaction: r) }
|
||||
}
|
||||
}
|
||||
|
||||
private func topReactionsCount(_ rs: [UIAction]) -> Int {
|
||||
rs.count > 4 ? 3 : 4
|
||||
}
|
||||
|
||||
private func setReaction(add: Bool, reaction: MsgReaction) {
|
||||
Task {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let chatItem = try await apiChatItemReaction(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
itemId: ci.id,
|
||||
add: add,
|
||||
reaction: reaction
|
||||
)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiChatItemReaction error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shareUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Share", comment: "chat item action"),
|
||||
@@ -589,6 +724,26 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func viewInfoUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Info", comment: "chat item action"),
|
||||
image: UIImage(systemName: "info.circle")
|
||||
) { _ in
|
||||
Task {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, itemId: ci.id)
|
||||
await MainActor.run {
|
||||
chatItemInfo = ciInfo
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGetChatItemInfo error: \(responseError(error))")
|
||||
}
|
||||
await MainActor.run { showChatItemInfoSheet = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelFileUIAction(_ fileId: Int64, _ cancelAction: CancelAction) -> UIAction {
|
||||
return UIAction(
|
||||
title: cancelAction.uiAction,
|
||||
@@ -669,7 +824,7 @@ struct ChatView: View {
|
||||
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
||||
switch (prevItem?.chatDir) {
|
||||
case .groupSnd: return true
|
||||
@@ -742,9 +897,20 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.enableNtfs = enableNtfs
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
func toggleChatFavorite(_ chat: Chat, favorite: Bool) {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.favorite = favorite
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
|
||||
Task {
|
||||
do {
|
||||
let chatSettings = ChatSettings(enableNtfs: enableNtfs)
|
||||
try await apiSetChatSettings(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, chatSettings: chatSettings)
|
||||
await MainActor.run {
|
||||
switch chat.chatInfo {
|
||||
|
||||
@@ -264,7 +264,7 @@ struct ComposeView: View {
|
||||
default: previewView()
|
||||
}
|
||||
HStack (alignment: .bottom) {
|
||||
Button {
|
||||
let b = Button {
|
||||
showChooseSource = true
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
@@ -274,11 +274,22 @@ struct ComposeView: View {
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.leading, 12)
|
||||
if case let .group(g) = chat.chatInfo,
|
||||
!g.fullGroupPreferences.files.on {
|
||||
b.disabled(true).onTapGesture {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Files and media prohibited!",
|
||||
message: "Only group owners can enable files and media."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
b
|
||||
}
|
||||
ZStack(alignment: .leading) {
|
||||
SendMessageView(
|
||||
composeState: $composeState,
|
||||
sendMessage: {
|
||||
sendMessage()
|
||||
sendMessage: { ttl in
|
||||
sendMessage(ttl: ttl)
|
||||
resetLinkPreview()
|
||||
},
|
||||
sendLiveMessage: sendLiveMessage,
|
||||
@@ -296,6 +307,7 @@ struct ComposeView: View {
|
||||
},
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
|
||||
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
|
||||
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
@@ -425,7 +437,7 @@ struct ComposeView: View {
|
||||
&& (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) {
|
||||
cancelCurrentVoiceRecording()
|
||||
clearCurrentDraft()
|
||||
sendMessage()
|
||||
sendMessage(ttl: nil)
|
||||
resetLinkPreview()
|
||||
} else if (composeState.inProgress) {
|
||||
clearCurrentDraft()
|
||||
@@ -470,7 +482,7 @@ struct ComposeView: View {
|
||||
let lm = composeState.liveMessage
|
||||
if (composeState.sendEnabled || composeState.quoting)
|
||||
&& (lm == nil || lm?.sentMsg == nil),
|
||||
let ci = await sendMessageAsync(typedMsg, live: true) {
|
||||
let ci = await sendMessageAsync(typedMsg, live: true, ttl: nil) {
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: typedMsg))
|
||||
}
|
||||
@@ -486,7 +498,7 @@ struct ComposeView: View {
|
||||
let typedMsg = composeState.message
|
||||
if let liveMessage = composeState.liveMessage {
|
||||
if let sentMsg = liveMessageToSend(liveMessage, typedMsg),
|
||||
let ci = await sendMessageAsync(sentMsg, live: true) {
|
||||
let ci = await sendMessageAsync(sentMsg, live: true, ttl: nil) {
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg))
|
||||
}
|
||||
@@ -578,15 +590,15 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
private func sendMessage(ttl: Int?) {
|
||||
logger.debug("ChatView sendMessage")
|
||||
Task {
|
||||
logger.debug("ChatView sendMessage: in Task")
|
||||
_ = await sendMessageAsync(nil, live: false)
|
||||
_ = await sendMessageAsync(nil, live: false, ttl: ttl)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendMessageAsync(_ text: String?, live: Bool) async -> ChatItem? {
|
||||
private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? {
|
||||
var sent: ChatItem?
|
||||
let msgText = text ?? composeState.message
|
||||
let liveMessage = composeState.liveMessage
|
||||
@@ -606,36 +618,36 @@ struct ComposeView: View {
|
||||
|
||||
switch (composeState.preview) {
|
||||
case .noPreview:
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live)
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
|
||||
case .linkPreview:
|
||||
sent = await send(checkLinkPreview(), quoted: quoted, live: live)
|
||||
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
|
||||
case let .mediaPreviews(mediaPreviews: media):
|
||||
let last = media.count - 1
|
||||
if last >= 0 {
|
||||
for i in 0..<last {
|
||||
if case (_, .video(_, _, _)) = media[i] {
|
||||
sent = await sendVideo(media[i])
|
||||
sent = await sendVideo(media[i], ttl: ttl)
|
||||
} else {
|
||||
sent = await sendImage(media[i])
|
||||
sent = await sendImage(media[i], ttl: ttl)
|
||||
}
|
||||
_ = try? await Task.sleep(nanoseconds: 100_000000)
|
||||
}
|
||||
if case (_, .video(_, _, _)) = media[last] {
|
||||
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live)
|
||||
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
|
||||
} else {
|
||||
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live)
|
||||
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
|
||||
}
|
||||
}
|
||||
if sent == nil {
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live)
|
||||
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
|
||||
}
|
||||
case let .voicePreview(recordingFileName, duration):
|
||||
stopPlayback.toggle()
|
||||
chatModel.filesToDelete.remove(getAppFilePath(recordingFileName))
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName)
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName, ttl: ttl)
|
||||
case let .filePreview(_, file):
|
||||
if let savedFile = saveFileFromURL(file) {
|
||||
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live)
|
||||
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -691,30 +703,31 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false) async -> ChatItem? {
|
||||
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
let (image, data) = imageData
|
||||
if let data = data, let savedFile = saveAnyImage(data) {
|
||||
return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live)
|
||||
return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false) async -> ChatItem? {
|
||||
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) {
|
||||
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live)
|
||||
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) async -> ChatItem? {
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
if let chatItem = await apiSendMessage(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
file: file,
|
||||
quotedItemId: quoted,
|
||||
msg: mc,
|
||||
live: live
|
||||
live: live,
|
||||
ttl: ttl
|
||||
) {
|
||||
await MainActor.run {
|
||||
chatModel.removeLiveDummy(animated: false)
|
||||
@@ -722,6 +735,9 @@ struct ComposeView: View {
|
||||
}
|
||||
return chatItem
|
||||
}
|
||||
if let file = file {
|
||||
removeFile(file)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ struct ComposeVoiceView: View {
|
||||
@State private var playbackTime: TimeInterval?
|
||||
@State private var startingPlayback: Bool = false
|
||||
|
||||
private static let previewHeight: CGFloat = 50
|
||||
private static let previewHeight: CGFloat = 55
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -66,6 +66,7 @@ struct ComposeVoiceView: View {
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 12)
|
||||
.padding(.top, 4)
|
||||
|
||||
ProgressBar(length: MAX_VOICE_MESSAGE_LENGTH, progress: $recordingTime)
|
||||
}
|
||||
@@ -105,9 +106,12 @@ struct ComposeVoiceView: View {
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 12)
|
||||
.padding(.top, 4)
|
||||
|
||||
if let recordingLength = recordingTime {
|
||||
ProgressBar(length: recordingLength, progress: $playbackTime)
|
||||
GeometryReader { _ in
|
||||
SliderBar(length: recordingLength, progress: $playbackTime, seek: { audioPlayer?.seek($0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: stopPlayback) { _ in
|
||||
@@ -145,6 +149,18 @@ struct ComposeVoiceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SliderBar: View {
|
||||
var length: TimeInterval
|
||||
@Binding var progress: TimeInterval?
|
||||
var seek: (TimeInterval) -> Void
|
||||
|
||||
var body: some View {
|
||||
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProgressBar: View {
|
||||
var length: TimeInterval
|
||||
@Binding var progress: TimeInterval?
|
||||
@@ -154,10 +170,10 @@ struct ComposeVoiceView: View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 3)
|
||||
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
|
||||
.animation(.linear, value: progress)
|
||||
}
|
||||
.frame(height: ComposeVoiceView.previewHeight - 1, alignment: .bottom) // minus 1 is for the bottom padding
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,8 +188,7 @@ struct ComposeVoiceView: View {
|
||||
playbackTime = recordingTime // animate progress bar to the end
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileName: recordingFileName)
|
||||
playbackTime = TimeInterval(0)
|
||||
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
|
||||
playbackState = .playing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,31 +104,33 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
var images: [UploadContent] = []
|
||||
var rangeDiff = 0
|
||||
let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
textView.attributedText.enumerateAttribute(
|
||||
NSAttributedString.Key.attachment,
|
||||
in: NSRange(location: 0, length: textView.attributedText.length),
|
||||
options: [],
|
||||
using: { value, range, _ in
|
||||
if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
||||
do {
|
||||
images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
||||
} catch {
|
||||
if let img = (value as? NSTextAttachment)?.image {
|
||||
images.append(.simpleImage(image: img))
|
||||
if textView.markedTextRange == nil {
|
||||
var images: [UploadContent] = []
|
||||
var rangeDiff = 0
|
||||
let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
textView.attributedText.enumerateAttribute(
|
||||
NSAttributedString.Key.attachment,
|
||||
in: NSRange(location: 0, length: textView.attributedText.length),
|
||||
options: [],
|
||||
using: { value, range, _ in
|
||||
if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
||||
do {
|
||||
images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
||||
} catch {
|
||||
if let img = (value as? NSTextAttachment)?.image {
|
||||
images.append(.simpleImage(image: img))
|
||||
}
|
||||
}
|
||||
newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
||||
rangeDiff += range.length
|
||||
}
|
||||
newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
||||
rangeDiff += range.length
|
||||
}
|
||||
)
|
||||
if textView.attributedText != newAttributedText {
|
||||
textView.attributedText = newAttributedText
|
||||
}
|
||||
)
|
||||
if textView.attributedText != newAttributedText {
|
||||
textView.attributedText = newAttributedText
|
||||
onTextChanged(textView.text, images)
|
||||
}
|
||||
onTextChanged(textView.text, images)
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
|
||||
@@ -13,7 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000
|
||||
|
||||
struct SendMessageView: View {
|
||||
@Binding var composeState: ComposeState
|
||||
var sendMessage: () -> Void
|
||||
var sendMessage: (Int?) -> Void
|
||||
var sendLiveMessage: (() async -> Void)? = nil
|
||||
var updateLiveMessage: (() async -> Void)? = nil
|
||||
var cancelLiveMessage: (() -> Void)? = nil
|
||||
@@ -23,6 +23,7 @@ struct SendMessageView: View {
|
||||
var startVoiceMessageRecording: (() -> Void)? = nil
|
||||
var finishVoiceMessageRecording: (() -> Void)? = nil
|
||||
var allowVoiceMessagesToContact: (() -> Void)? = nil
|
||||
var timedMessageAllowed: Bool = false
|
||||
var onMediaAdded: ([UploadContent]) -> Void
|
||||
@State private var holdingVMR = false
|
||||
@Namespace var namespace
|
||||
@@ -32,6 +33,9 @@ struct SendMessageView: View {
|
||||
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
|
||||
@State private var sendButtonSize: CGFloat = 29
|
||||
@State private var sendButtonOpacity: CGFloat = 1
|
||||
@State private var showCustomDisappearingMessageDialogue = false
|
||||
@State private var showCustomTimePicker = false
|
||||
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
|
||||
var maxHeight: CGFloat = 360
|
||||
var minHeight: CGFloat = 37
|
||||
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
@@ -147,15 +151,17 @@ struct SendMessageView: View {
|
||||
.padding([.top, .trailing], 4)
|
||||
}
|
||||
|
||||
@ViewBuilder private func sendMessageButton() -> some View {
|
||||
let v = Button(action: sendMessage) {
|
||||
private func sendMessageButton() -> some View {
|
||||
Button {
|
||||
sendMessage(nil)
|
||||
} label: {
|
||||
Image(systemName: composeState.editing || composeState.liveMessage != nil
|
||||
? "checkmark.circle.fill"
|
||||
: "arrow.up.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: sendButtonSize, height: sendButtonSize)
|
||||
.opacity(sendButtonOpacity)
|
||||
? "checkmark.circle.fill"
|
||||
: "arrow.up.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: sendButtonSize, height: sendButtonSize)
|
||||
.opacity(sendButtonOpacity)
|
||||
}
|
||||
.disabled(
|
||||
!composeState.sendEnabled ||
|
||||
@@ -164,22 +170,61 @@ struct SendMessageView: View {
|
||||
composeState.endLiveDisabled
|
||||
)
|
||||
.frame(width: 29, height: 29)
|
||||
.contextMenu{
|
||||
sendButtonContextMenuItems()
|
||||
}
|
||||
.padding([.bottom, .trailing], 4)
|
||||
.confirmationDialog("Send disappearing message", isPresented: $showCustomDisappearingMessageDialogue, titleVisibility: .visible) {
|
||||
Button("30 seconds") { sendMessage(30) }
|
||||
Button("1 minute") { sendMessage(60) }
|
||||
Button("5 minutes") { sendMessage(300) }
|
||||
Button("Custom time") { showCustomTimePicker = true }
|
||||
}
|
||||
.sheet(isPresented: $showCustomTimePicker, onDismiss: { selectedDisappearingMessageTime = customDisappearingMessageTimeDefault.get() }) {
|
||||
if #available(iOS 16.0, *) {
|
||||
disappearingMessageCustomTimePicker()
|
||||
.presentationDetents([.medium])
|
||||
} else {
|
||||
disappearingMessageCustomTimePicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func disappearingMessageCustomTimePicker() -> some View {
|
||||
CustomTimePickerView(
|
||||
selection: $selectedDisappearingMessageTime,
|
||||
confirmButtonText: "Send",
|
||||
confirmButtonAction: {
|
||||
if let time = selectedDisappearingMessageTime {
|
||||
sendMessage(time)
|
||||
customDisappearingMessageTimeDefault.set(time)
|
||||
}
|
||||
},
|
||||
description: "Delete after"
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private func sendButtonContextMenuItems() -> some View {
|
||||
if composeState.liveMessage == nil,
|
||||
case .noContextItem = composeState.contextItem,
|
||||
!composeState.voicePreview && !composeState.editing,
|
||||
let send = sendLiveMessage,
|
||||
let update = updateLiveMessage {
|
||||
v.contextMenu{
|
||||
!composeState.editing {
|
||||
if case .noContextItem = composeState.contextItem,
|
||||
!composeState.voicePreview,
|
||||
let send = sendLiveMessage,
|
||||
let update = updateLiveMessage {
|
||||
Button {
|
||||
startLiveMessage(send: send, update: update)
|
||||
} label: {
|
||||
Label("Send live message", systemImage: "bolt.fill")
|
||||
}
|
||||
}
|
||||
.padding([.bottom, .trailing], 4)
|
||||
} else {
|
||||
v.padding([.bottom, .trailing], 4)
|
||||
if timedMessageAllowed {
|
||||
Button {
|
||||
hideKeyboard()
|
||||
showCustomDisappearingMessageDialogue = true
|
||||
} label: {
|
||||
Label("Disappearing message", systemImage: "stopwatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +410,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
Spacer(minLength: 0)
|
||||
SendMessageView(
|
||||
composeState: $composeStateNew,
|
||||
sendMessage: {},
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
@@ -375,7 +420,7 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
Spacer(minLength: 0)
|
||||
SendMessageView(
|
||||
composeState: $composeStateEditing,
|
||||
sendMessage: {},
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
|
||||
@@ -24,8 +24,9 @@ struct ContactPreferencesView: View {
|
||||
List {
|
||||
timedMessagesFeatureSection()
|
||||
featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete)
|
||||
featureSection(.reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, $featuresAllowed.reactions)
|
||||
featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice)
|
||||
featureSection(.calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, $featuresAllowed.calls).disabled(true)
|
||||
featureSection(.calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, $featuresAllowed.calls)
|
||||
|
||||
Section {
|
||||
Button("Reset") { featuresAllowed = currentFeaturesAllowed }
|
||||
@@ -89,9 +90,16 @@ struct ContactPreferencesView: View {
|
||||
}
|
||||
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||
if featuresAllowed.timedMessagesAllowed {
|
||||
timedMessagesTTLPicker($featuresAllowed.timedMessagesTTL)
|
||||
DropdownCustomTimePicker(
|
||||
selection: $featuresAllowed.timedMessagesTTL,
|
||||
label: "Delete after",
|
||||
dropdownValues: TimedMessagesPreference.ttlValues,
|
||||
customPickerConfirmButtonText: "Select",
|
||||
customPickerDescription: "Delete after"
|
||||
)
|
||||
.frame(height: 36)
|
||||
} else if pref.contactPreference.allow == .yes || pref.contactPreference.allow == .always {
|
||||
infoRow("Delete after", TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
|
||||
infoRow("Delete after", timeText(pref.contactPreference.ttl))
|
||||
}
|
||||
}
|
||||
header: { featureHeader(.timedMessages, enabled) }
|
||||
@@ -107,7 +115,7 @@ struct ContactPreferencesView: View {
|
||||
}
|
||||
|
||||
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||
(Text(feature.enabledDescription(enabled)) + (feature == .calls ? Text("\nAvailable in v5.1").bold() : Text("")))
|
||||
Text(feature.enabledDescription(enabled))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
|
||||
@@ -129,18 +137,6 @@ struct ContactPreferencesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func timedMessagesTTLPicker(_ selection: Binding<Int?>) -> some View {
|
||||
Picker("Delete after", selection: selection) {
|
||||
let selectedTTL = selection.wrappedValue
|
||||
let ttlValues = TimedMessagesPreference.ttlValues
|
||||
let values = ttlValues + (ttlValues.contains(selectedTTL) ? [] : [selectedTTL])
|
||||
ForEach(values, id: \.self) { ttl in
|
||||
Text(TimedMessagesPreference.ttlText(ttl))
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
|
||||
struct ContactPreferencesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContactPreferencesView(
|
||||
|
||||
@@ -29,6 +29,8 @@ struct AddGroupMembersViewCommon: View {
|
||||
@State private var selectedContacts = Set<Int64>()
|
||||
@State private var selectedRole: GroupMemberRole = .member
|
||||
@State private var alert: AddGroupMembersAlert?
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
|
||||
private enum AddGroupMembersAlert: Identifiable {
|
||||
case prohibitedToInviteIncognito
|
||||
@@ -85,7 +87,7 @@ struct AddGroupMembersViewCommon: View {
|
||||
if showFooterCounter {
|
||||
if (count >= 1) {
|
||||
HStack {
|
||||
Button { selectedContacts.removeAll() } label: { Text("Clear") }
|
||||
Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) }
|
||||
Spacer()
|
||||
Text("\(count) contact(s) selected")
|
||||
}
|
||||
@@ -97,7 +99,11 @@ struct AddGroupMembersViewCommon: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(membersToAdd) { contact in
|
||||
searchFieldView(text: $searchText, focussed: $searchFocussed)
|
||||
.padding(.leading, 2)
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
||||
ForEach(members) { contact in
|
||||
contactCheckView(contact)
|
||||
}
|
||||
}
|
||||
@@ -116,6 +122,9 @@ struct AddGroupMembersViewCommon: View {
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedContacts) { _ in
|
||||
searchFocussed = false
|
||||
}
|
||||
}
|
||||
|
||||
private func inviteMembersButton() -> some View {
|
||||
@@ -199,6 +208,31 @@ struct AddGroupMembersViewCommon: View {
|
||||
}
|
||||
}
|
||||
|
||||
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding) -> some View {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 20)
|
||||
.padding(.trailing, 10)
|
||||
TextField("Search", text: text)
|
||||
.focused(focussed)
|
||||
.foregroundColor(.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.opacity(text.wrappedValue == "" ? 0 : 1)
|
||||
.frame(height: 20)
|
||||
.onTapGesture {
|
||||
text.wrappedValue = ""
|
||||
focussed.wrappedValue = false
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.frame(height: 36)
|
||||
}
|
||||
|
||||
struct AddGroupMembersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddGroupMembersView(chat: Chat(chatInfo: ChatInfo.sampleData.group), groupInfo: GroupInfo.sampleData)
|
||||
|
||||
@@ -22,6 +22,8 @@ struct GroupChatInfoView: View {
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var connectionCode: String?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
|
||||
enum GroupChatInfoViewAlert: Identifiable {
|
||||
case deleteGroupAlert
|
||||
@@ -67,8 +69,14 @@ struct GroupChatInfoView: View {
|
||||
addMembersButton()
|
||||
}
|
||||
}
|
||||
if members.count > 8 {
|
||||
searchFieldView(text: $searchText, focussed: $searchFocussed)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let filteredMembers = s == "" ? members : members.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
||||
memberView(groupInfo.membership, user: true)
|
||||
ForEach(members) { member in
|
||||
ForEach(filteredMembers) { member in
|
||||
ZStack {
|
||||
NavigationLink {
|
||||
memberInfoView(member.groupMemberId)
|
||||
@@ -144,6 +152,7 @@ struct GroupChatInfoView: View {
|
||||
NavigationLink {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
.onAppear {
|
||||
searchFocussed = false
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
|
||||
@@ -29,43 +29,33 @@ struct GroupLinkView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack (alignment: .leading) {
|
||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||
.padding(.bottom)
|
||||
List {
|
||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
Section {
|
||||
if let groupLink = groupLink {
|
||||
HStack {
|
||||
Text("Initial role")
|
||||
Picker("Initial role", selection: $groupLinkMemberRole) {
|
||||
ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
Picker("Initial role", selection: $groupLinkMemberRole) {
|
||||
ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 36)
|
||||
QRCode(uri: groupLink)
|
||||
HStack {
|
||||
Button {
|
||||
showShareSheet(items: [groupLink])
|
||||
} label: {
|
||||
Label("Share link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.padding()
|
||||
|
||||
Button(role: .destructive) { alert = .deleteLink } label: {
|
||||
Label("Delete link", systemImage: "trash")
|
||||
}
|
||||
.padding()
|
||||
Button {
|
||||
showShareSheet(items: [groupLink])
|
||||
} label: {
|
||||
Label("Share link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Button(role: .destructive) { alert = .deleteLink } label: {
|
||||
Label("Delete link", systemImage: "trash")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Button(action: createGroupLink) {
|
||||
Label("Create link", systemImage: "link.badge.plus")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(creatingLink)
|
||||
.padding(.bottom)
|
||||
if creatingLink {
|
||||
ProgressView()
|
||||
.scaleEffect(2)
|
||||
@@ -73,8 +63,6 @@ struct GroupLinkView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alert in
|
||||
switch alert {
|
||||
case .deleteLink:
|
||||
|
||||
@@ -20,19 +20,26 @@ struct GroupMemberInfoView: View {
|
||||
@State private var newRole: GroupMemberRole = .member
|
||||
@State private var alert: GroupMemberInfoViewAlert?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var justOpened = true
|
||||
|
||||
enum GroupMemberInfoViewAlert: Identifiable {
|
||||
case removeMemberAlert(mem: GroupMember)
|
||||
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case connRequestSentAlert(type: ConnReqType)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case other(alert: Alert)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .removeMemberAlert: return "removeMemberAlert"
|
||||
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case .connRequestSentAlert: return "connRequestSentAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
case let .other(alert): return "other \(alert)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +52,15 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func knownDirectChat(_ contactId: Int64) -> Chat? {
|
||||
if let chat = chatModel.getContactChat(contactId),
|
||||
chat.chatInfo.contact?.directOrUsed == true {
|
||||
return chat
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func groupMemberInfoView() -> some View {
|
||||
VStack {
|
||||
List {
|
||||
@@ -54,8 +70,7 @@ struct GroupMemberInfoView: View {
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let contactId = member.memberContactId {
|
||||
if let chat = chatModel.getContactChat(contactId),
|
||||
chat.chatInfo.contact?.directOrUsed ?? false {
|
||||
if let chat = knownDirectChat(contactId) {
|
||||
knownDirectChatButton(chat)
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on {
|
||||
newDirectChatButton(contactId)
|
||||
@@ -73,6 +88,13 @@ struct GroupMemberInfoView: View {
|
||||
} 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)
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
} footer: {
|
||||
@@ -107,8 +129,15 @@ struct GroupMemberInfoView: View {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvServers)
|
||||
smpServers("Sending via", connStats.sndServers)
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil })
|
||||
if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch })
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +156,10 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
.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)
|
||||
@@ -137,6 +170,7 @@ struct GroupMemberInfoView: View {
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
justOpened = false
|
||||
}
|
||||
.onChange(of: newRole) { _ in
|
||||
if newRole != member.memberRole {
|
||||
@@ -150,7 +184,29 @@ struct GroupMemberInfoView: View {
|
||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
|
||||
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||
case let .connRequestSentAlert(type): return connReqSentAlert(type)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .other(alert): return alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaAddressButton(_ contactLink: String) -> some View {
|
||||
Button {
|
||||
connectViaAddress(contactLink)
|
||||
} label: {
|
||||
Label("Connect", systemImage: "link")
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaAddress(_ contactLink: String) {
|
||||
Task {
|
||||
let (connReqType, connectAlert) = await apiConnect_(connReq: contactLink)
|
||||
if let connReqType = connReqType {
|
||||
alert = .connRequestSentAlert(type: connReqType)
|
||||
} else if let connectAlert = connectAlert {
|
||||
alert = .other(alert: connectAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,7 +356,8 @@ struct GroupMemberInfoView: View {
|
||||
private func switchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
} catch let error {
|
||||
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -310,6 +367,21 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func abortSwitchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
} catch let error {
|
||||
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error aborting address change")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||
|
||||
@@ -25,7 +25,10 @@ struct GroupPreferencesView: View {
|
||||
featureSection(.timedMessages, $preferences.timedMessages.enable)
|
||||
featureSection(.fullDelete, $preferences.fullDelete.enable)
|
||||
featureSection(.directMessages, $preferences.directMessages.enable)
|
||||
featureSection(.reactions, $preferences.reactions.enable)
|
||||
featureSection(.voice, $preferences.voice.enable)
|
||||
// TODO uncomment in 5.3
|
||||
// featureSection(.files, $preferences.files.enable)
|
||||
|
||||
if groupInfo.canEdit {
|
||||
Section {
|
||||
@@ -75,14 +78,21 @@ struct GroupPreferencesView: View {
|
||||
Toggle(feature.text, isOn: enable)
|
||||
}
|
||||
if timedOn {
|
||||
timedMessagesTTLPicker($preferences.timedMessages.ttl)
|
||||
DropdownCustomTimePicker(
|
||||
selection: $preferences.timedMessages.ttl,
|
||||
label: "Delete after",
|
||||
dropdownValues: TimedMessagesPreference.ttlValues,
|
||||
customPickerConfirmButtonText: "Select",
|
||||
customPickerDescription: "Delete after"
|
||||
)
|
||||
.frame(height: 36)
|
||||
}
|
||||
} else {
|
||||
settingsRow(icon, color: color) {
|
||||
infoRow(Text(feature.text), enableFeature.wrappedValue.text)
|
||||
}
|
||||
if timedOn {
|
||||
infoRow("Delete after", TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
||||
infoRow("Delete after", timeText(preferences.timedMessages.ttl))
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
|
||||
@@ -55,6 +55,8 @@ struct GroupWelcomeView: View {
|
||||
private func textPreview() -> some View {
|
||||
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil)
|
||||
.allowsHitTesting(false)
|
||||
.frame(minHeight: 140, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func editorView() -> some View {
|
||||
@@ -73,12 +75,11 @@ struct GroupWelcomeView: View {
|
||||
}
|
||||
.padding(.horizontal, -5)
|
||||
.padding(.top, -8)
|
||||
.frame(height: 90, alignment: .topLeading)
|
||||
.frame(height: 140, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
textPreview()
|
||||
.frame(height: 90, alignment: .topLeading)
|
||||
}
|
||||
|
||||
Button {
|
||||
|
||||
@@ -27,7 +27,7 @@ private let rowHeights: [DynamicTypeSize: CGFloat] = [
|
||||
struct ChatListNavLink: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@State var chat: Chat
|
||||
@ObservedObject var chat: Chat
|
||||
@State private var showContactRequestDialog = false
|
||||
@State private var showJoinGroupDialog = false
|
||||
@State private var showContactConnectionInfo = false
|
||||
@@ -57,6 +57,8 @@ struct ChatListNavLink: View {
|
||||
)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
toggleNtfsButton(chat)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !chat.chatItems.isEmpty {
|
||||
@@ -108,6 +110,14 @@ struct ChatListNavLink: View {
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
leaveGroupChatButton(groupInfo)
|
||||
}
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupChatButton(groupInfo)
|
||||
}
|
||||
}
|
||||
default:
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
@@ -118,21 +128,16 @@ struct ChatListNavLink: View {
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
toggleNtfsButton(chat)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !chat.chatItems.isEmpty {
|
||||
clearChatButton()
|
||||
}
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
|
||||
} label: {
|
||||
Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
.tint(Color.yellow)
|
||||
leaveGroupChatButton(groupInfo)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupChatButton(groupInfo)
|
||||
}
|
||||
@@ -168,6 +173,24 @@ struct ChatListNavLink: View {
|
||||
|
||||
}
|
||||
|
||||
@ViewBuilder private func toggleFavoriteButton() -> some View {
|
||||
if chat.chatInfo.chatSettings?.favorite == true {
|
||||
Button {
|
||||
toggleChatFavorite(chat, favorite: false)
|
||||
} label: {
|
||||
Label("Unfav.", systemImage: "star.slash")
|
||||
}
|
||||
.tint(.green)
|
||||
} else {
|
||||
Button {
|
||||
toggleChatFavorite(chat, favorite: true)
|
||||
} label: {
|
||||
Label("Favorite", systemImage: "star.fill")
|
||||
}
|
||||
.tint(.green)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearChatButton() -> some View {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(clearChatAlert())
|
||||
@@ -177,7 +200,16 @@ struct ChatListNavLink: View {
|
||||
.tint(Color.orange)
|
||||
}
|
||||
|
||||
@ViewBuilder private func deleteGroupChatButton(_ groupInfo: GroupInfo) -> some View {
|
||||
private func leaveGroupChatButton(_ groupInfo: GroupInfo) -> some View {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
|
||||
} label: {
|
||||
Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
.tint(Color.yellow)
|
||||
}
|
||||
|
||||
private func deleteGroupChatButton(_ groupInfo: GroupInfo) -> some View {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
|
||||
} label: {
|
||||
|
||||
@@ -14,7 +14,8 @@ struct ChatListView: View {
|
||||
@Binding var showSettings: Bool
|
||||
@State private var searchText = ""
|
||||
@State private var showAddChat = false
|
||||
@State var userPickerVisible = false
|
||||
@State private var userPickerVisible = false
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
@@ -29,11 +30,7 @@ struct ChatListView: View {
|
||||
if chatModel.chats.isEmpty {
|
||||
onboardingButtons()
|
||||
}
|
||||
if chatModel.chats.count > 8 {
|
||||
chatList.searchable(text: $searchText)
|
||||
} else {
|
||||
chatList
|
||||
}
|
||||
chatListView
|
||||
}
|
||||
}
|
||||
if userPickerVisible {
|
||||
@@ -47,18 +44,12 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var chatList: some View {
|
||||
List {
|
||||
ForEach(filteredChats(), id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
|
||||
chatModel.chatToTop = nil
|
||||
chatModel.popChat(chatId)
|
||||
private var chatListView: some View {
|
||||
VStack {
|
||||
if chatModel.chats.count > 0 {
|
||||
chatList.searchable(text: $searchText)
|
||||
} else {
|
||||
chatList
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
||||
@@ -66,7 +57,6 @@ struct ChatListView: View {
|
||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
||||
.offset(x: -8)
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Your chats")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
@@ -94,17 +84,19 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
if (chatModel.incognito) {
|
||||
HStack {
|
||||
if (chatModel.chats.count > 8) {
|
||||
Text("Your chats").font(.headline)
|
||||
Spacer().frame(width: 16)
|
||||
}
|
||||
Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo)
|
||||
HStack(spacing: 4) {
|
||||
if (chatModel.incognito) {
|
||||
Image(systemName: "theatermasks")
|
||||
.foregroundColor(.indigo)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Text("Chats")
|
||||
.font(.headline)
|
||||
if chatModel.chats.count > 0 {
|
||||
toggleFilterButton()
|
||||
}
|
||||
} else {
|
||||
Text("Your chats").font(.headline)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
switch chatModel.chatRunning {
|
||||
@@ -116,6 +108,37 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleFilterButton() -> some View {
|
||||
Button {
|
||||
showUnreadAndFavorites = !showUnreadAndFavorites
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : ""))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var chatList: some View {
|
||||
let cs = filteredChats()
|
||||
ZStack {
|
||||
List {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
|
||||
chatModel.chatToTop = nil
|
||||
chatModel.popChat(chatId)
|
||||
}
|
||||
}
|
||||
if cs.isEmpty && !chatModel.chats.isEmpty {
|
||||
Text("No filtered chats").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
|
||||
Circle()
|
||||
.frame(width: size, height: size)
|
||||
@@ -175,19 +198,37 @@ struct ChatListView: View {
|
||||
|
||||
private func filteredChats() -> [Chat] {
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
return s == ""
|
||||
return s == "" && !showUnreadAndFavorites
|
||||
? chatModel.chats
|
||||
: chatModel.chats.filter { chat in
|
||||
let contains = chat.chatInfo.chatViewName.localizedLowercase.contains(s)
|
||||
switch chat.chatInfo {
|
||||
let cInfo = chat.chatInfo
|
||||
switch cInfo {
|
||||
case let .direct(contact):
|
||||
return contains
|
||||
|| contact.profile.displayName.localizedLowercase.contains(s)
|
||||
|| contact.fullName.localizedLowercase.contains(s)
|
||||
case .contactConnection: return false
|
||||
default: return contains
|
||||
return s == ""
|
||||
? filtered(chat)
|
||||
: (viewNameContains(cInfo, s) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||
contact.fullName.localizedLowercase.contains(s))
|
||||
case let .group(gInfo):
|
||||
return s == ""
|
||||
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
||||
: viewNameContains(cInfo, s)
|
||||
case .contactRequest:
|
||||
return s == "" || viewNameContains(cInfo, s)
|
||||
case let .contactConnection(conn):
|
||||
return s != "" && conn.localAlias.localizedLowercase.contains(s)
|
||||
case .invalidJSON:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func filtered(_ chat: Chat) -> Bool {
|
||||
(chat.chatInfo.chatSettings?.favorite ?? false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
||||
}
|
||||
|
||||
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
|
||||
cInfo.chatViewName.localizedLowercase.contains(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,13 @@ struct ChatPreviewView: View {
|
||||
} else if !chat.chatInfo.ntfsEnabled {
|
||||
Image(systemName: "speaker.slash.fill")
|
||||
.foregroundColor(.secondary)
|
||||
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
||||
Image(systemName: "star.fill")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(.trailing, 1)
|
||||
.foregroundColor(.secondary.opacity(0.65))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,7 +267,7 @@ struct ChatPreviewView_Previews: PreviewProvider {
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted)]
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted(deletedTs: .now))]
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
|
||||
@@ -14,6 +14,7 @@ enum DatabaseAlert: Identifiable {
|
||||
case exportProhibited
|
||||
case importArchive
|
||||
case archiveImported
|
||||
case archiveImportedWithErrors(archiveErrors: [ArchiveError])
|
||||
case deleteChat
|
||||
case chatDeleted
|
||||
case deleteLegacyDatabase
|
||||
@@ -27,6 +28,7 @@ enum DatabaseAlert: Identifiable {
|
||||
case .exportProhibited: return "exportProhibited"
|
||||
case .importArchive: return "importArchive"
|
||||
case .archiveImported: return "archiveImported"
|
||||
case .archiveImportedWithErrors: return "archiveImportedWithErrors"
|
||||
case .deleteChat: return "deleteChat"
|
||||
case .chatDeleted: return "chatDeleted"
|
||||
case .deleteLegacyDatabase: return "deleteLegacyDatabase"
|
||||
@@ -251,7 +253,11 @@ struct DatabaseView: View {
|
||||
title: Text("Chat database imported"),
|
||||
message: Text("Restart the app to use imported chat database")
|
||||
)
|
||||
|
||||
case .archiveImportedWithErrors:
|
||||
return Alert(
|
||||
title: Text("Chat database imported"),
|
||||
message: Text("Restart the app to use imported chat database") + Text("\n") + Text("Some non-fatal errors occurred during import - you may see Chat console for more details.")
|
||||
)
|
||||
case .deleteChat:
|
||||
return Alert(
|
||||
title: Text("Delete chat profile?"),
|
||||
@@ -317,10 +323,7 @@ struct DatabaseView: View {
|
||||
private func stopChat() {
|
||||
Task {
|
||||
do {
|
||||
try await apiStopChat()
|
||||
ChatReceiver.shared.stop()
|
||||
await MainActor.run { m.chatRunning = false }
|
||||
appStateGroupDefault.set(.stopped)
|
||||
try await stopChatAsync()
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
runChat = true
|
||||
@@ -354,9 +357,13 @@ struct DatabaseView: View {
|
||||
try await apiDeleteStorage()
|
||||
do {
|
||||
let config = ArchiveConfig(archivePath: archivePath.path)
|
||||
try await apiImportArchive(config: config)
|
||||
let archiveErrors = try await apiImportArchive(config: config)
|
||||
_ = kcDatabasePassword.remove()
|
||||
await operationEnded(.archiveImported)
|
||||
if archiveErrors.isEmpty {
|
||||
await operationEnded(.archiveImported)
|
||||
} else {
|
||||
await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors))
|
||||
}
|
||||
} catch let error {
|
||||
await operationEnded(.error(title: "Error importing chat database", error: responseError(error)))
|
||||
}
|
||||
@@ -374,9 +381,7 @@ struct DatabaseView: View {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
try await apiDeleteStorage()
|
||||
_ = kcDatabasePassword.remove()
|
||||
storeDBPassphraseGroupDefault.set(true)
|
||||
try await deleteChatAsync()
|
||||
await operationEnded(.chatDeleted)
|
||||
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
|
||||
} catch let error {
|
||||
@@ -468,6 +473,19 @@ struct DatabaseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func stopChatAsync() async throws {
|
||||
try await apiStopChat()
|
||||
ChatReceiver.shared.stop()
|
||||
await MainActor.run { ChatModel.shared.chatRunning = false }
|
||||
appStateGroupDefault.set(.stopped)
|
||||
}
|
||||
|
||||
func deleteChatAsync() async throws {
|
||||
try await apiDeleteStorage()
|
||||
_ = kcDatabasePassword.remove()
|
||||
storeDBPassphraseGroupDefault.set(true)
|
||||
}
|
||||
|
||||
struct DatabaseView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
DatabaseView(showSettings: Binding.constant(false), chatItemTTL: .none)
|
||||
|
||||
@@ -109,6 +109,7 @@ struct MigrateToAppGroupView: View {
|
||||
do {
|
||||
resetChatCtrl()
|
||||
try initializeChat(start: true)
|
||||
onboardingStageDefault.set(.step4_SetNotificationsMode)
|
||||
chatModel.onboardingStage = .step4_SetNotificationsMode
|
||||
setV3DBMigration(.ready)
|
||||
} catch let error {
|
||||
@@ -202,7 +203,7 @@ struct MigrateToAppGroupView: View {
|
||||
dbContainerGroupDefault.set(.group)
|
||||
resetChatCtrl()
|
||||
try await MainActor.run { try initializeChat(start: false) }
|
||||
try await apiImportArchive(config: config)
|
||||
let _ = try await apiImportArchive(config: config)
|
||||
await MainActor.run { setV3DBMigration(.migrated) }
|
||||
} catch let error {
|
||||
dbContainerGroupDefault.set(.documents)
|
||||
|
||||
@@ -11,11 +11,12 @@ import UIKit
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func uiKitContextMenu(menu: Binding<UIMenu>) -> some View {
|
||||
self.overlay(Color(uiColor: .systemBackground))
|
||||
.overlay(
|
||||
InteractionView(content: self, menu: menu)
|
||||
)
|
||||
func uiKitContextMenu(menu: Binding<UIMenu>, allowMenu: Binding<Bool>) -> some View {
|
||||
self.overlay {
|
||||
if allowMenu.wrappedValue {
|
||||
self.overlay(Color(uiColor: .systemBackground)).overlay(InteractionView(content: self, menu: menu))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//
|
||||
// CustomTimePicker.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 11.05.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CustomTimePicker: View {
|
||||
@Binding var selection: Int?
|
||||
@State var timeUnitsLimits = TimeUnitLimits.defaultUnitsLimits
|
||||
@State private var selectedUnit: CustomTimeUnit = .second
|
||||
@State private var selectedDuration: Int = 1
|
||||
|
||||
struct TimeUnitLimits {
|
||||
var timeUnit: CustomTimeUnit
|
||||
var minValue: Int = 1
|
||||
var maxValue: Int
|
||||
|
||||
public static func defaultUnitLimits(_ unit: CustomTimeUnit) -> TimeUnitLimits {
|
||||
switch unit {
|
||||
case .second: return TimeUnitLimits.init(timeUnit: .second, maxValue: 120)
|
||||
case .minute: return TimeUnitLimits.init(timeUnit: .minute, maxValue: 120)
|
||||
case .hour: return TimeUnitLimits.init(timeUnit: .hour, maxValue: 72)
|
||||
case .day: return TimeUnitLimits.init(timeUnit: .day, maxValue: 60)
|
||||
case .week: return TimeUnitLimits.init(timeUnit: .week, maxValue: 52)
|
||||
case .month: return TimeUnitLimits.init(timeUnit: .month, maxValue: 12)
|
||||
}
|
||||
}
|
||||
|
||||
public static var defaultUnitsLimits: [TimeUnitLimits] {[
|
||||
defaultUnitLimits(.second),
|
||||
defaultUnitLimits(.minute),
|
||||
defaultUnitLimits(.hour),
|
||||
defaultUnitLimits(.day),
|
||||
defaultUnitLimits(.week),
|
||||
defaultUnitLimits(.month),
|
||||
]}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Group {
|
||||
Picker("Duration", selection: $selectedDuration) {
|
||||
let selectedUnitLimits = timeUnitsLimits.first(where: { $0.timeUnit == selectedUnit }) ?? TimeUnitLimits.defaultUnitLimits(selectedUnit)
|
||||
let selectedUnitValues = Array(selectedUnitLimits.minValue...selectedUnitLimits.maxValue)
|
||||
let values = selectedUnitValues + (selectedUnitValues.contains(selectedDuration) ? [] : [selectedDuration])
|
||||
ForEach(values, id: \.self) { value in
|
||||
Text("\(value)")
|
||||
}
|
||||
}
|
||||
Picker("Unit", selection: $selectedUnit) {
|
||||
ForEach(timeUnitsLimits.map { $0.timeUnit }, id: \.self) { timeUnit in
|
||||
Text(timeUnit.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(minWidth: 0)
|
||||
.compositingGroup()
|
||||
.clipped()
|
||||
}
|
||||
.onAppear {
|
||||
if let selection = selection,
|
||||
selection > 0 {
|
||||
(selectedUnit, selectedDuration) = CustomTimeUnit.toTimeUnit(seconds: selection)
|
||||
} else {
|
||||
selection = selectedUnit.toSeconds * selectedDuration
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedUnit) { unit in
|
||||
if let maxValue = timeUnitsLimits.first(where: { $0.timeUnit == unit })?.maxValue,
|
||||
selectedDuration > maxValue {
|
||||
selectedDuration = maxValue
|
||||
} else {
|
||||
selection = unit.toSeconds * selectedDuration
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedDuration) { duration in
|
||||
selection = selectedUnit.toSeconds * duration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIPickerView {
|
||||
open override var intrinsicContentSize: CGSize {
|
||||
return CGSize(width: UIView.noIntrinsicMetric, height: super.intrinsicContentSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomTimePickerView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Binding var selection: Int?
|
||||
var confirmButtonText: LocalizedStringKey
|
||||
var confirmButtonAction: () -> Void
|
||||
var description: LocalizedStringKey? = nil
|
||||
var timeUnitsLimits = CustomTimePicker.TimeUnitLimits.defaultUnitsLimits
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
customTimePickerView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
confirmButtonAction()
|
||||
dismiss()
|
||||
} label: {
|
||||
Text(confirmButtonText)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.disabled(selection == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func customTimePickerView() -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
List {
|
||||
Group {
|
||||
Section(description ?? "") {
|
||||
CustomTimePicker(selection: $selection)
|
||||
}
|
||||
}
|
||||
.listRowInsets(.init(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DropdownCustomTimePicker: View {
|
||||
@Binding var selection: Int?
|
||||
var label: LocalizedStringKey
|
||||
var dropdownValues: [Int?]
|
||||
var customPickerConfirmButtonText: LocalizedStringKey
|
||||
var customPickerDescription: LocalizedStringKey? = nil
|
||||
var customPickerTimeUnitsLimits = CustomTimePicker.TimeUnitLimits.defaultUnitsLimits
|
||||
@State private var dropdownSelection: DropdownSelection = .dropdownValue(value: nil)
|
||||
@State private var showCustomTimePicker = false
|
||||
@State private var selectedCustomTime: Int? = nil
|
||||
@State private var justOpened = true
|
||||
|
||||
enum DropdownSelection: Hashable {
|
||||
case dropdownValue(value: Int?)
|
||||
case custom
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Picker(label, selection: $dropdownSelection) {
|
||||
let values: [DropdownSelection] =
|
||||
dropdownValues.map { .dropdownValue(value: $0) }
|
||||
+ (dropdownValues.contains(selection) ? [] : [.dropdownValue(value: selection)])
|
||||
+ [.custom]
|
||||
ForEach(values, id: \.self) { v in
|
||||
switch v {
|
||||
case let .dropdownValue(value): Text(timeText(value))
|
||||
case .custom: Text(NSLocalizedString("custom", comment: "dropdown time picker choice"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
dropdownSelection = .dropdownValue(value: selection)
|
||||
justOpened = false
|
||||
}
|
||||
.onChange(of: selection) { v in
|
||||
logger.debug("*** .onChange(of: selection)")
|
||||
dropdownSelection = .dropdownValue(value: v)
|
||||
}
|
||||
.onChange(of: dropdownSelection) { v in
|
||||
logger.debug("*** .onChange(of: dropdownSelection)")
|
||||
switch v {
|
||||
case let .dropdownValue(value): selection = value
|
||||
case .custom: showCustomTimePicker = true
|
||||
}
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $showCustomTimePicker,
|
||||
onDismiss: {
|
||||
dropdownSelection = .dropdownValue(value: selection)
|
||||
selectedCustomTime = nil
|
||||
}
|
||||
) {
|
||||
if #available(iOS 16.0, *) {
|
||||
customTimePicker()
|
||||
.presentationDetents([.medium])
|
||||
} else {
|
||||
customTimePicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func customTimePicker() -> some View {
|
||||
CustomTimePickerView(
|
||||
selection: $selectedCustomTime,
|
||||
confirmButtonText: customPickerConfirmButtonText,
|
||||
confirmButtonAction: {
|
||||
if let time = selectedCustomTime {
|
||||
selection = time
|
||||
}
|
||||
},
|
||||
description: customPickerDescription,
|
||||
timeUnitsLimits: customPickerTimeUnitsLimits
|
||||
)
|
||||
.onAppear {
|
||||
selectedCustomTime = selection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomTimePicker_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CustomTimePicker(
|
||||
selection: Binding.constant(300)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -30,19 +30,26 @@ struct LocalAuthRequest {
|
||||
var title: LocalizedStringKey? // if title is null, reason is shown
|
||||
var reason: String
|
||||
var password: String
|
||||
var selfDestruct: Bool
|
||||
var completed: (LAResult) -> Void
|
||||
|
||||
static var sample = LocalAuthRequest(title: "Enter Passcode", reason: "Authenticate", password: "", completed: { _ in })
|
||||
static var sample = LocalAuthRequest(title: "Enter Passcode", reason: "Authenticate", password: "", selfDestruct: false, completed: { _ in })
|
||||
}
|
||||
|
||||
func authenticate(title: LocalizedStringKey? = nil, reason: String, completed: @escaping (LAResult) -> Void) {
|
||||
func authenticate(title: LocalizedStringKey? = nil, reason: String, selfDestruct: Bool = false, completed: @escaping (LAResult) -> Void) {
|
||||
logger.debug("authenticate")
|
||||
switch privacyLocalAuthModeDefault.get() {
|
||||
case .system: systemAuthenticate(reason, completed)
|
||||
case .passcode:
|
||||
if let password = kcAppPassword.get() {
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.laRequest = LocalAuthRequest(title: title, reason: reason, password: password, completed: completed)
|
||||
ChatModel.shared.laRequest = LocalAuthRequest(
|
||||
title: title,
|
||||
reason: reason,
|
||||
password: password,
|
||||
selfDestruct: selfDestruct && UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT),
|
||||
completed: completed
|
||||
)
|
||||
}
|
||||
} else {
|
||||
completed(.unavailable(authError: NSLocalizedString("No app password", comment: "Authentication unavailable")))
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct LocalAuthView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@@ -15,6 +16,13 @@ struct LocalAuthView: View {
|
||||
|
||||
var body: some View {
|
||||
PasscodeView(passcode: $password, title: authRequest.title ?? "Enter Passcode", reason: authRequest.reason, submitLabel: "Submit") {
|
||||
if let sdPassword = kcSelfDestructPassword.get(), authRequest.selfDestruct && password == sdPassword {
|
||||
deleteStorageAndRestart(sdPassword) { r in
|
||||
m.laRequest = nil
|
||||
authRequest.completed(r)
|
||||
}
|
||||
return
|
||||
}
|
||||
let r: LAResult = password == authRequest.password
|
||||
? .success
|
||||
: .failed(authError: NSLocalizedString("Incorrect passcode", comment: "PIN entry"))
|
||||
@@ -25,6 +33,41 @@ struct LocalAuthView: View {
|
||||
authRequest.completed(.failed(authError: NSLocalizedString("Authentication cancelled", comment: "PIN entry")))
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteStorageAndRestart(_ password: String, completed: @escaping (LAResult) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
try await stopChatAsync()
|
||||
try await deleteChatAsync()
|
||||
_ = kcAppPassword.set(password)
|
||||
_ = kcSelfDestructPassword.remove()
|
||||
await NtfManager.shared.removeAllNotifications()
|
||||
let displayName = UserDefaults.standard.string(forKey: DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME)
|
||||
UserDefaults.standard.removeObject(forKey: DEFAULT_LA_SELF_DESTRUCT)
|
||||
UserDefaults.standard.removeObject(forKey: DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME)
|
||||
await MainActor.run {
|
||||
m.chatDbChanged = true
|
||||
m.chatInitialized = false
|
||||
}
|
||||
resetChatCtrl()
|
||||
try initializeChat(start: true)
|
||||
m.chatDbChanged = false
|
||||
appStateGroupDefault.set(.active)
|
||||
if m.currentUser != nil { return }
|
||||
var profile: Profile? = nil
|
||||
if let displayName = displayName, displayName != "" {
|
||||
profile = Profile(displayName: displayName, fullName: "")
|
||||
}
|
||||
m.currentUser = try apiCreateActiveUser(profile, pastTimestamp: true)
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
try startChat()
|
||||
completed(.success)
|
||||
} catch {
|
||||
completed(.failed(authError: NSLocalizedString("Incorrect passcode", comment: "PIN entry")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalAuthView_Previews: PreviewProvider {
|
||||
|
||||
@@ -10,6 +10,9 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct SetAppPasscodeView: View {
|
||||
var passcodeKeychain: KeyChainItem = kcAppPassword
|
||||
var title: LocalizedStringKey = "New Passcode"
|
||||
var reason: String?
|
||||
var submit: () -> Void
|
||||
var cancel: () -> Void
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@@ -27,7 +30,7 @@ struct SetAppPasscodeView: View {
|
||||
submitEnabled: { pwd in pwd == enteredPassword }
|
||||
) {
|
||||
if passcode == enteredPassword {
|
||||
if kcAppPassword.set(passcode) {
|
||||
if passcodeKeychain.set(passcode) {
|
||||
enteredPassword = ""
|
||||
passcode = ""
|
||||
dismiss()
|
||||
@@ -38,7 +41,7 @@ struct SetAppPasscodeView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setPasswordView(title: "New Passcode", submitLabel: "Save") {
|
||||
setPasswordView(title: title, submitLabel: "Save") {
|
||||
enteredPassword = passcode
|
||||
passcode = ""
|
||||
confirming = true
|
||||
@@ -51,7 +54,7 @@ struct SetAppPasscodeView: View {
|
||||
}
|
||||
|
||||
private func setPasswordView(title: LocalizedStringKey, submitLabel: LocalizedStringKey, submitEnabled: (((String) -> Bool))? = nil, submit: @escaping () -> Void) -> some View {
|
||||
PasscodeView(passcode: $passcode, title: title, submitLabel: submitLabel, submitEnabled: submitEnabled, submit: submit) {
|
||||
PasscodeView(passcode: $passcode, title: title, reason: reason, submitLabel: submitLabel, submitEnabled: submitEnabled, submit: submit) {
|
||||
dismiss()
|
||||
cancel()
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func connectViaLink(_ connectionLink: String, _ dismiss: DismissAction? = nil) {
|
||||
if let connReqType = await apiConnect(connReq: connectionLink) {
|
||||
DispatchQueue.main.async {
|
||||
dismiss?()
|
||||
connectionReqSentAlert(connReqType)
|
||||
AlertManager.shared.showAlert(connReqSentAlert(connReqType))
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
@@ -111,8 +111,8 @@ func groupLinkAlert(_ connectionLink: String) -> Alert {
|
||||
)
|
||||
}
|
||||
|
||||
func connectionReqSentAlert(_ type: ConnReqType) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
func connReqSentAlert(_ type: ConnReqType) -> Alert {
|
||||
return mkAlert(
|
||||
title: "Connection request sent!",
|
||||
message: type == .contact
|
||||
? "You will be connected when your connection request is accepted, please wait or check later!"
|
||||
|
||||
@@ -124,8 +124,13 @@ struct CreateProfile: View {
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
if m.users.isEmpty {
|
||||
try startChat()
|
||||
withAnimation { m.onboardingStage = .step3_CreateSimpleXAddress }
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
|
||||
m.onboardingStage = .step3_CreateSimpleXAddress
|
||||
}
|
||||
} else {
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
|
||||
@@ -100,12 +100,10 @@ struct CreateSimpleXAddress: View {
|
||||
} label: {
|
||||
Text("Create SimpleX address").font(.title)
|
||||
}
|
||||
Group {
|
||||
Text("Your contacts in SimpleX will see it.\nYou can change it in Settings.")
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, 32)
|
||||
Text("Your contacts in SimpleX will see it.\nYou can change it in Settings.")
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +111,7 @@ struct CreateSimpleXAddress: View {
|
||||
VStack(spacing: 8) {
|
||||
Button {
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(.step4_SetNotificationsMode)
|
||||
m.onboardingStage = .step4_SetNotificationsMode
|
||||
}
|
||||
} label: {
|
||||
@@ -154,6 +153,7 @@ struct CreateSimpleXAddress: View {
|
||||
case let .success(composeResult):
|
||||
switch composeResult {
|
||||
case .sent:
|
||||
onboardingStageDefault.set(.step4_SetNotificationsMode)
|
||||
m.onboardingStage = .step4_SetNotificationsMode
|
||||
default: ()
|
||||
}
|
||||
@@ -173,6 +173,7 @@ struct CreateSimpleXAddress: View {
|
||||
private func continueButton() -> some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(.step4_SetNotificationsMode)
|
||||
m.onboardingStage = .step4_SetNotificationsMode
|
||||
}
|
||||
} label: {
|
||||
@@ -190,14 +191,14 @@ struct SendAddressMailView: View {
|
||||
var userAddress: UserContactLink
|
||||
|
||||
var body: some View {
|
||||
let messageBody = """
|
||||
let messageBody = String(format: NSLocalizedString("""
|
||||
<p>Hi!</p>
|
||||
<p><a href="\(userAddress.connReqContact)">Connect to me via SimpleX Chat</a></p>
|
||||
"""
|
||||
<p><a href="%@">Connect to me via SimpleX Chat</a></p>
|
||||
""", comment: "email text"), userAddress.connReqContact)
|
||||
MailView(
|
||||
isShowing: self.$showMailView,
|
||||
result: $mailViewResult,
|
||||
subject: "Let's talk in SimpleX Chat",
|
||||
subject: NSLocalizedString("Let's talk in SimpleX Chat", comment: "email subject"),
|
||||
messageBody: messageBody
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,12 +22,14 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum OnboardingStage {
|
||||
enum OnboardingStage: String, Identifiable {
|
||||
case step1_SimpleXInfo
|
||||
case step2_CreateProfile
|
||||
case step3_CreateSimpleXAddress
|
||||
case step4_SetNotificationsMode
|
||||
case onboardingComplete
|
||||
|
||||
public var id: Self { self }
|
||||
}
|
||||
|
||||
struct OnboardingStepsView_Previews: PreviewProvider {
|
||||
|
||||
@@ -35,6 +35,7 @@ struct SetNotificationsMode: View {
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(title: "No device token!")
|
||||
}
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
} label: {
|
||||
if case .off = notificationMode {
|
||||
|
||||
@@ -99,6 +99,7 @@ struct OnboardingActionButton: View {
|
||||
private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(onboarding)
|
||||
m.onboardingStage = onboarding
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
|
||||
private struct VersionDescription {
|
||||
var version: String
|
||||
var post: URL?
|
||||
var features: [FeatureDescription]
|
||||
}
|
||||
|
||||
@@ -22,11 +23,12 @@ private struct FeatureDescription {
|
||||
private let versionDescriptions: [VersionDescription] = [
|
||||
VersionDescription(
|
||||
version: "v4.2",
|
||||
post: URL(string: "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "checkmark.shield",
|
||||
title: "Security assessment",
|
||||
description: "SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)."
|
||||
description: "SimpleX Chat security was audited by Trail of Bits."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "person.2",
|
||||
@@ -42,6 +44,7 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.3",
|
||||
post: URL(string: "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "mic",
|
||||
@@ -67,6 +70,7 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.4",
|
||||
post: URL(string: "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "stopwatch",
|
||||
@@ -97,6 +101,7 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.5",
|
||||
post: URL(string: "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "person.crop.rectangle.stack",
|
||||
@@ -132,6 +137,7 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.6",
|
||||
post: URL(string: "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "lock",
|
||||
@@ -167,6 +173,7 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.0",
|
||||
post: URL(string: "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "arrow.up.doc",
|
||||
@@ -184,6 +191,35 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
]
|
||||
),
|
||||
// Also
|
||||
// preference to disable calls per contact
|
||||
// access welcome message via a group profile
|
||||
VersionDescription(
|
||||
version: "v5.1",
|
||||
post: URL(string: "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
icon: "face.smiling",
|
||||
title: "Message reactions",
|
||||
description: "Finally, we have them! 🚀"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "arrow.up.message",
|
||||
title: "Better messages",
|
||||
description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "lock",
|
||||
title: "Self-destruct passcode",
|
||||
description: "All data is erased when it is entered."
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "character",
|
||||
title: "Japanese interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -219,6 +255,14 @@ struct WhatsNewView: View {
|
||||
featureDescription(f.icon, f.title, f.description)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
if let post = v.post {
|
||||
Link(destination: post) {
|
||||
HStack {
|
||||
Text("Read more")
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !viaSettings {
|
||||
Spacer()
|
||||
Button("Ok") {
|
||||
|
||||
@@ -21,6 +21,7 @@ struct TerminalView: View {
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
@State private var terminalItem: TerminalItem?
|
||||
@State private var scrolled = false
|
||||
|
||||
var body: some View {
|
||||
if authorized {
|
||||
@@ -51,7 +52,12 @@ struct TerminalView: View {
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.onAppear { scrollToBottom(proxy) }
|
||||
.onAppear {
|
||||
if !scrolled {
|
||||
scrollToBottom(proxy)
|
||||
scrolled = true
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.terminalItems.count) { _ in scrollToBottom(proxy) }
|
||||
.onChange(of: keyboardVisible) { _ in
|
||||
if keyboardVisible {
|
||||
@@ -72,7 +78,7 @@ struct TerminalView: View {
|
||||
|
||||
SendMessageView(
|
||||
composeState: $composeState,
|
||||
sendMessage: sendMessage,
|
||||
sendMessage: { _ in consoleSendMessage() },
|
||||
showVoiceMessageButton: false,
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
@@ -108,7 +114,7 @@ struct TerminalView: View {
|
||||
.onDisappear { terminalItem = nil }
|
||||
}
|
||||
|
||||
func sendMessage() {
|
||||
func consoleSendMessage() {
|
||||
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")))
|
||||
|
||||
@@ -13,6 +13,7 @@ struct IncognitoHelp: View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Incognito mode")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
@@ -20,8 +20,9 @@ struct PreferencesView: View {
|
||||
List {
|
||||
timedMessagesFeatureSection($preferences.timedMessages.allow)
|
||||
featureSection(.fullDelete, $preferences.fullDelete.allow)
|
||||
featureSection(.reactions, $preferences.reactions.allow)
|
||||
featureSection(.voice, $preferences.voice.allow)
|
||||
featureSection(.calls, $preferences.calls.allow).disabled(true)
|
||||
featureSection(.calls, $preferences.calls.allow)
|
||||
|
||||
Section {
|
||||
Button("Reset") { preferences = currentPreferences }
|
||||
@@ -61,7 +62,7 @@ struct PreferencesView: View {
|
||||
}
|
||||
|
||||
private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
|
||||
(Text(feature.allowDescription(allowFeature.wrappedValue)) + (feature == .calls ? Text("\nAvailable in v5.1").bold() : Text("")))
|
||||
Text(feature.allowDescription(allowFeature.wrappedValue))
|
||||
.frame(height: 36, alignment: .topLeading)
|
||||
}
|
||||
|
||||
|
||||
@@ -102,9 +102,13 @@ struct SimplexLockView: View {
|
||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||
@State private var laMode: LAMode = privacyLocalAuthModeDefault.get()
|
||||
@AppStorage(DEFAULT_LA_LOCK_DELAY) private var laLockDelay = 30
|
||||
@State var performLA: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
@State private var performLA: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||
@State private var selfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
|
||||
@State private var currentSelfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
|
||||
@AppStorage(DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME) private var selfDestructDisplayName = ""
|
||||
@State private var performLAToggleReset = false
|
||||
@State private var performLAModeReset = false
|
||||
@State private var performLASelfDestructReset = false
|
||||
@State private var showPasswordAction: PasswordAction? = nil
|
||||
@State private var showChangePassword = false
|
||||
@State var laAlert: LASettingViewAlert? = nil
|
||||
@@ -116,6 +120,8 @@ struct SimplexLockView: View {
|
||||
case laUnavailableTurningOffAlert
|
||||
case laPasscodeSetAlert
|
||||
case laPasscodeChangedAlert
|
||||
case laSelfDestructPasscodeSetAlert
|
||||
case laSelfDestructPasscodeChangedAlert
|
||||
case laPasscodeNotChangedAlert
|
||||
|
||||
var id: Self { self }
|
||||
@@ -124,7 +130,10 @@ struct SimplexLockView: View {
|
||||
enum PasswordAction: Identifiable {
|
||||
case enableAuth
|
||||
case toggleMode
|
||||
case changePassword
|
||||
case changePasscode
|
||||
case enableSelfDestruct
|
||||
case changeSelfDestructPasscode
|
||||
case selfDestructInfo
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
@@ -159,12 +168,34 @@ struct SimplexLockView: View {
|
||||
}
|
||||
}
|
||||
if showChangePassword && laMode == .passcode {
|
||||
Button("Change Passcode") {
|
||||
Button("Change passcode") {
|
||||
changeLAPassword()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if performLA && laMode == .passcode {
|
||||
Section("Self-destruct passcode") {
|
||||
Toggle(isOn: $selfDestruct) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Enable self-destruct")
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.onTapGesture {
|
||||
showPasswordAction = .selfDestructInfo
|
||||
}
|
||||
}
|
||||
if selfDestruct {
|
||||
TextField("New display name", text: $selfDestructDisplayName)
|
||||
Button("Change self-destruct passcode") {
|
||||
changeSelfDestructPassword()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: performLA) { performLAToggle in
|
||||
@@ -192,6 +223,13 @@ struct SimplexLockView: View {
|
||||
updateLAMode()
|
||||
}
|
||||
}
|
||||
.onChange(of: selfDestruct) { _ in
|
||||
if performLASelfDestructReset {
|
||||
performLASelfDestructReset = false
|
||||
} else if prefPerformLA {
|
||||
toggleSelfDestruct()
|
||||
}
|
||||
}
|
||||
.alert(item: $laAlert) { alertItem in
|
||||
switch alertItem {
|
||||
case .laTurnedOnAlert: return laTurnedOnAlert()
|
||||
@@ -200,6 +238,8 @@ struct SimplexLockView: View {
|
||||
case .laUnavailableTurningOffAlert: return laUnavailableTurningOffAlert()
|
||||
case .laPasscodeSetAlert: return passcodeAlert("Passcode set!")
|
||||
case .laPasscodeChangedAlert: return passcodeAlert("Passcode changed!")
|
||||
case .laSelfDestructPasscodeSetAlert: return selfDestructPasscodeAlert("Self-destruct passcode enabled!")
|
||||
case .laSelfDestructPasscodeChangedAlert: return selfDestructPasscodeAlert("Self-destruct passcode changed!")
|
||||
case .laPasscodeNotChangedAlert: return mkAlert(title: "Passcode not changed!")
|
||||
}
|
||||
}
|
||||
@@ -223,12 +263,27 @@ struct SimplexLockView: View {
|
||||
} cancel: {
|
||||
revertLAMode()
|
||||
}
|
||||
case .changePassword:
|
||||
case .changePasscode:
|
||||
SetAppPasscodeView {
|
||||
showLAAlert(.laPasscodeChangedAlert)
|
||||
} cancel: {
|
||||
showLAAlert(.laPasscodeNotChangedAlert)
|
||||
}
|
||||
case .enableSelfDestruct:
|
||||
SetAppPasscodeView(passcodeKeychain: kcSelfDestructPassword, title: "Set passcode", reason: NSLocalizedString("Enable self-destruct passcode", comment: "set passcode view")) {
|
||||
updateSelfDestruct()
|
||||
showLAAlert(.laSelfDestructPasscodeSetAlert)
|
||||
} cancel: {
|
||||
revertSelfDestruct()
|
||||
}
|
||||
case .changeSelfDestructPasscode:
|
||||
SetAppPasscodeView(passcodeKeychain: kcSelfDestructPassword, reason: NSLocalizedString("Change self-destruct passcode", comment: "set passcode view")) {
|
||||
showLAAlert(.laSelfDestructPasscodeChangedAlert)
|
||||
} cancel: {
|
||||
showLAAlert(.laPasscodeNotChangedAlert)
|
||||
}
|
||||
case .selfDestructInfo:
|
||||
selfDestructInfoView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -239,6 +294,30 @@ struct SimplexLockView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func selfDestructInfoView() -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Self-destruct")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Text("If you enter your self-destruct passcode while opening the app:")
|
||||
VStack(spacing: 8) {
|
||||
textListItem("1.", "All app data is deleted.")
|
||||
textListItem("2.", "App passcode is replaced with self-destruct passcode.")
|
||||
textListItem("3.", "An empty chat profile with the provided name is created, and the app opens as usual.")
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func showLAAlert(_ a: LASettingViewAlert) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
laAlert = a
|
||||
@@ -259,6 +338,7 @@ struct SimplexLockView: View {
|
||||
switch laResult {
|
||||
case .success:
|
||||
_ = kcAppPassword.remove()
|
||||
resetSelfDestruct()
|
||||
laAlert = .laTurnedOnAlert
|
||||
case .failed, .unavailable:
|
||||
currentLAMode = .passcode
|
||||
@@ -276,11 +356,39 @@ struct SimplexLockView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleSelfDestruct() {
|
||||
authenticate(reason: NSLocalizedString("Change self-destruct mode", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .failed:
|
||||
revertSelfDestruct()
|
||||
laAlert = .laFailedAlert
|
||||
case .success:
|
||||
if selfDestruct {
|
||||
showPasswordAction = .enableSelfDestruct
|
||||
} else {
|
||||
resetSelfDestruct()
|
||||
}
|
||||
case .unavailable:
|
||||
disableUnavailableLA()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func changeLAPassword() {
|
||||
authenticate(title: "Current Passcode", reason: NSLocalizedString("Change passcode", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .failed: laAlert = .laFailedAlert
|
||||
case .success: showPasswordAction = .changePassword
|
||||
case .success: showPasswordAction = .changePasscode
|
||||
case .unavailable: disableUnavailableLA()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func changeSelfDestructPassword() {
|
||||
authenticate(reason: NSLocalizedString("Change self-destruct passcode", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .failed: laAlert = .laFailedAlert
|
||||
case .success: showPasswordAction = .changeSelfDestructPasscode
|
||||
case .unavailable: disableUnavailableLA()
|
||||
}
|
||||
}
|
||||
@@ -329,6 +437,7 @@ struct SimplexLockView: View {
|
||||
_ = kcAppPassword.remove()
|
||||
laLockDelay = 30
|
||||
showChangePassword = false
|
||||
resetSelfDestruct()
|
||||
}
|
||||
|
||||
private func resetLAEnabled(_ onOff: Bool) {
|
||||
@@ -347,9 +456,29 @@ struct SimplexLockView: View {
|
||||
privacyLocalAuthModeDefault.set(laMode)
|
||||
}
|
||||
|
||||
private func resetSelfDestruct() {
|
||||
_ = kcSelfDestructPassword.remove()
|
||||
selfDestruct = false
|
||||
updateSelfDestruct()
|
||||
}
|
||||
|
||||
private func revertSelfDestruct() {
|
||||
performLASelfDestructReset = true
|
||||
withAnimation { selfDestruct = currentSelfDestruct }
|
||||
}
|
||||
|
||||
private func updateSelfDestruct() {
|
||||
UserDefaults.standard.set(selfDestruct, forKey: DEFAULT_LA_SELF_DESTRUCT)
|
||||
currentSelfDestruct = selfDestruct
|
||||
}
|
||||
|
||||
private func passcodeAlert(_ title: LocalizedStringKey) -> Alert {
|
||||
mkAlert(title: title, message: "Please remember or store it securely - there is no way to recover a lost passcode!")
|
||||
}
|
||||
|
||||
private func selfDestructPasscodeAlert(_ title: LocalizedStringKey) -> Alert {
|
||||
mkAlert(title: title, message: "If you enter this passcode when opening the app, all app data will be irreversibly removed!")
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivacySettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -21,6 +21,8 @@ let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown"
|
||||
let DEFAULT_PERFORM_LA = "performLocalAuthentication"
|
||||
let DEFAULT_LA_MODE = "localAuthenticationMode"
|
||||
let DEFAULT_LA_LOCK_DELAY = "localAuthenticationLockDelay"
|
||||
let DEFAULT_LA_SELF_DESTRUCT = "localAuthenticationSelfDestruct"
|
||||
let DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME = "localAuthenticationSelfDestructDisplayName"
|
||||
let DEFAULT_NOTIFICATION_ALERT_SHOWN = "notificationAlertShown"
|
||||
let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay"
|
||||
let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers"
|
||||
@@ -45,6 +47,9 @@ let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
||||
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
|
||||
let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert"
|
||||
let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion"
|
||||
let DEFAULT_ONBOARDING_STAGE = "onboardingStage"
|
||||
let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime"
|
||||
let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites"
|
||||
|
||||
let appDefaults: [String: Any] = [
|
||||
DEFAULT_SHOW_LA_NOTICE: false,
|
||||
@@ -52,6 +57,7 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_PERFORM_LA: false,
|
||||
DEFAULT_LA_MODE: LAMode.system.rawValue,
|
||||
DEFAULT_LA_LOCK_DELAY: 30,
|
||||
DEFAULT_LA_SELF_DESTRUCT: false,
|
||||
DEFAULT_NOTIFICATION_ALERT_SHOWN: false,
|
||||
DEFAULT_WEBRTC_POLICY_RELAY: true,
|
||||
DEFAULT_CALL_KIT_CALLS_IN_RECENTS: false,
|
||||
@@ -71,6 +77,9 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
|
||||
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
|
||||
DEFAULT_SHOW_MUTE_PROFILE_ALERT: true,
|
||||
DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue,
|
||||
DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300,
|
||||
DEFAULT_SHOW_UNREAD_AND_FAVORITES: false
|
||||
]
|
||||
|
||||
enum SimpleXLinkMode: String, Identifiable {
|
||||
@@ -105,6 +114,10 @@ let privacySimplexLinkModeDefault = EnumDefault<SimpleXLinkMode>(defaults: UserD
|
||||
|
||||
let privacyLocalAuthModeDefault = EnumDefault<LAMode>(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system)
|
||||
|
||||
let onboardingStageDefault = EnumDefault<OnboardingStage>(defaults: UserDefaults.standard, forKey: DEFAULT_ONBOARDING_STAGE, withDefault: .onboardingComplete)
|
||||
|
||||
let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME)
|
||||
|
||||
func setGroupDefaults() {
|
||||
privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES))
|
||||
}
|
||||
@@ -294,9 +307,8 @@ struct SettingsView: View {
|
||||
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
.foregroundColor(chatModel.incognito ? Color.indigo : .secondary)
|
||||
Toggle(isOn: $chatModel.incognito) {
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Text("Incognito")
|
||||
Spacer().frame(width: 4)
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 14))
|
||||
|
||||
@@ -9,7 +9,6 @@ import SimpleXChat
|
||||
struct UserProfilesView: View {
|
||||
@EnvironmentObject private var m: ChatModel
|
||||
@Environment(\.editMode) private var editMode
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true
|
||||
@AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true
|
||||
@State private var showDeleteConfirmation = false
|
||||
@@ -157,7 +156,7 @@ struct UserProfilesView: View {
|
||||
case .hiddenProfilesNotice:
|
||||
return Alert(
|
||||
title: Text("Make profile private!"),
|
||||
message: Text("You can hide or mute a user profile - swipe it to the right.\nSimpleX Lock must be enabled."),
|
||||
message: Text("You can hide or mute a user profile - swipe it to the right."),
|
||||
primaryButton: .default(Text("Don't show again")) {
|
||||
showHiddenProfilesNotice = false
|
||||
},
|
||||
@@ -338,7 +337,7 @@ struct UserProfilesView: View {
|
||||
}
|
||||
.tint(.green)
|
||||
} else {
|
||||
if visibleUsersCount > 1 && prefPerformLA {
|
||||
if visibleUsersCount > 1 {
|
||||
Button("Hide") {
|
||||
selectedUser = user
|
||||
}
|
||||
|
||||
@@ -2433,8 +2433,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Show preview</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)." xml:space="preserve">
|
||||
<source>SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).</source>
|
||||
<trans-unit id="SimpleX Chat security was audited by Trail of Bits." xml:space="preserve">
|
||||
<source>SimpleX Chat security was audited by Trail of Bits.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Lock" xml:space="preserve">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,137 +5,171 @@
|
||||
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="14.2" build-num="14C18"/>
|
||||
</header>
|
||||
<body>
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<trans-unit id=" " xml:space="preserve" approved="no">
|
||||
<source>
|
||||
</source>
|
||||
<target state="translated">
|
||||
</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<trans-unit id=" " xml:space="preserve" approved="no">
|
||||
<source> </source>
|
||||
<target state="translated"> </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<trans-unit id=" " xml:space="preserve" approved="no">
|
||||
<source> </source>
|
||||
<target state="translated"> </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<trans-unit id=" " xml:space="preserve" approved="no">
|
||||
<source> </source>
|
||||
<target state="translated"> </target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" (" xml:space="preserve">
|
||||
<trans-unit id=" (" xml:space="preserve" approved="no">
|
||||
<source> (</source>
|
||||
<target state="translated"> (</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" (can be copied)" xml:space="preserve">
|
||||
<trans-unit id=" (can be copied)" xml:space="preserve" approved="no">
|
||||
<source> (can be copied)</source>
|
||||
<target state="translated"> (voidaan kopioida)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="!1 colored!" xml:space="preserve">
|
||||
<trans-unit id="!1 colored!" xml:space="preserve" approved="no">
|
||||
<source>!1 colored!</source>
|
||||
<target state="translated">!1 värillinen!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="#secret#" xml:space="preserve">
|
||||
<trans-unit id="#secret#" xml:space="preserve" approved="no">
|
||||
<source>#secret#</source>
|
||||
<target state="translated">#salaisuus#</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@" xml:space="preserve">
|
||||
<trans-unit id="%@" xml:space="preserve" approved="no">
|
||||
<source>%@</source>
|
||||
<target state="translated">% @</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ %@" xml:space="preserve">
|
||||
<trans-unit id="%@ %@" xml:space="preserve" approved="no">
|
||||
<source>%@ %@</source>
|
||||
<target state="translated">%@ % @</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ / %@" xml:space="preserve">
|
||||
<trans-unit id="%@ / %@" xml:space="preserve" approved="no">
|
||||
<source>%@ / %@</source>
|
||||
<target state="translated">%@ / % @</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is connected!" xml:space="preserve">
|
||||
<trans-unit id="%@ is connected!" xml:space="preserve" approved="no">
|
||||
<source>%@ is connected!</source>
|
||||
<target state="translated">%@ on yhdistetty!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is not verified" xml:space="preserve">
|
||||
<trans-unit id="%@ is not verified" xml:space="preserve" approved="no">
|
||||
<source>%@ is not verified</source>
|
||||
<target state="translated">%@ ei ole vahvistettu</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is verified" xml:space="preserve">
|
||||
<trans-unit id="%@ is verified" xml:space="preserve" approved="no">
|
||||
<source>%@ is verified</source>
|
||||
<target state="translated">%@ on vahvistettu</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ wants to connect!" xml:space="preserve">
|
||||
<trans-unit id="%@ wants to connect!" xml:space="preserve" approved="no">
|
||||
<source>%@ wants to connect!</source>
|
||||
<target state="translated">%@ haluaa muodostaa yhteyden!</target>
|
||||
<note>notification title</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d days" xml:space="preserve">
|
||||
<trans-unit id="%d days" xml:space="preserve" approved="no">
|
||||
<source>%d days</source>
|
||||
<target state="translated">%d päivää</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d hours" xml:space="preserve">
|
||||
<trans-unit id="%d hours" xml:space="preserve" approved="no">
|
||||
<source>%d hours</source>
|
||||
<target state="translated">%d tuntia</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d min" xml:space="preserve">
|
||||
<trans-unit id="%d min" xml:space="preserve" approved="no">
|
||||
<source>%d min</source>
|
||||
<target state="translated">%d min</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d months" xml:space="preserve">
|
||||
<trans-unit id="%d months" xml:space="preserve" approved="no">
|
||||
<source>%d months</source>
|
||||
<target state="translated">%d kuukautta</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d sec" xml:space="preserve">
|
||||
<trans-unit id="%d sec" xml:space="preserve" approved="no">
|
||||
<source>%d sec</source>
|
||||
<target state="translated">%d sek</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d skipped message(s)" xml:space="preserve">
|
||||
<trans-unit id="%d skipped message(s)" xml:space="preserve" approved="no">
|
||||
<source>%d skipped message(s)</source>
|
||||
<target state="translated">%d ohitettua viestiä</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld" xml:space="preserve">
|
||||
<trans-unit id="%lld" xml:space="preserve" approved="no">
|
||||
<source>%lld</source>
|
||||
<target state="translated">%lld</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld %@" xml:space="preserve">
|
||||
<trans-unit id="%lld %@" xml:space="preserve" approved="no">
|
||||
<source>%lld %@</source>
|
||||
<target state="translated">%lld %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld contact(s) selected" xml:space="preserve">
|
||||
<trans-unit id="%lld contact(s) selected" xml:space="preserve" approved="no">
|
||||
<source>%lld contact(s) selected</source>
|
||||
<target state="translated">%lld kontaktia valittu</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld file(s) with total size of %@" xml:space="preserve">
|
||||
<trans-unit id="%lld file(s) with total size of %@" xml:space="preserve" approved="no">
|
||||
<source>%lld file(s) with total size of %@</source>
|
||||
<target state="translated">%lld tiedosto(a), joiden kokonaiskoko on %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld members" xml:space="preserve">
|
||||
<trans-unit id="%lld members" xml:space="preserve" approved="no">
|
||||
<source>%lld members</source>
|
||||
<target state="translated">%lld jäsenet</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld second(s)" xml:space="preserve">
|
||||
<trans-unit id="%lld second(s)" xml:space="preserve" approved="no">
|
||||
<source>%lld second(s)</source>
|
||||
<target state="translated">%lld sekunti(a)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldd" xml:space="preserve">
|
||||
<trans-unit id="%lldd" xml:space="preserve" approved="no">
|
||||
<source>%lldd</source>
|
||||
<target state="translated">%lldd</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldh" xml:space="preserve">
|
||||
<trans-unit id="%lldh" xml:space="preserve" approved="no">
|
||||
<source>%lldh</source>
|
||||
<target state="translated">%lldh</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldk" xml:space="preserve">
|
||||
<trans-unit id="%lldk" xml:space="preserve" approved="no">
|
||||
<source>%lldk</source>
|
||||
<target state="translated">%lldk</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldm" xml:space="preserve">
|
||||
<trans-unit id="%lldm" xml:space="preserve" approved="no">
|
||||
<source>%lldm</source>
|
||||
<target state="translated">%lldm</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldmth" xml:space="preserve">
|
||||
<trans-unit id="%lldmth" xml:space="preserve" approved="no">
|
||||
<source>%lldmth</source>
|
||||
<target state="translated">%lldmth</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%llds" xml:space="preserve">
|
||||
<trans-unit id="%llds" xml:space="preserve" approved="no">
|
||||
<source>%llds</source>
|
||||
<target state="translated">%llds</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldw" xml:space="preserve">
|
||||
@@ -2496,8 +2530,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Show preview</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)." xml:space="preserve">
|
||||
<source>SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).</source>
|
||||
<trans-unit id="SimpleX Chat security was audited by Trail of Bits." xml:space="preserve">
|
||||
<source>SimpleX Chat security was audited by Trail of Bits.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Lock" xml:space="preserve">
|
||||
@@ -3750,6 +3784,41 @@ SimpleX servers cannot see your profile.</source>
|
||||
<source>\~strike~</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ (current)" xml:space="preserve" approved="no">
|
||||
<source>%@ (current)</source>
|
||||
<target state="translated">%@ (nykyinen)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ (current):" xml:space="preserve" approved="no">
|
||||
<source>%@ (current):</source>
|
||||
<target state="translated">% (nykyinen):</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ servers" xml:space="preserve" approved="no">
|
||||
<source>%@ servers</source>
|
||||
<target state="translated">%@ palvelimet</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld minutes" xml:space="preserve" approved="no">
|
||||
<source>%lld minutes</source>
|
||||
<target state="translated">%lld minuuttia</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@:" xml:space="preserve" approved="no">
|
||||
<source>%@:</source>
|
||||
<target state="needs-translation">%@:</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d weeks" xml:space="preserve" approved="no">
|
||||
<source>%d weeks</source>
|
||||
<target state="translated">%d viikkoa</target>
|
||||
<note>time interval</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld seconds" xml:space="preserve" approved="no">
|
||||
<source>%lld seconds</source>
|
||||
<target state="translated">%lld sekuntia</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="fi" datatype="plaintext">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2318,8 +2318,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Show preview</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)." xml:space="preserve">
|
||||
<source>SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).</source>
|
||||
<trans-unit id="SimpleX Chat security was audited by Trail of Bits." xml:space="preserve">
|
||||
<source>SimpleX Chat security was audited by Trail of Bits.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Lock" xml:space="preserve">
|
||||
|
||||
@@ -2429,8 +2429,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Show preview</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)." xml:space="preserve">
|
||||
<source>SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).</source>
|
||||
<trans-unit id="SimpleX Chat security was audited by Trail of Bits." xml:space="preserve">
|
||||
<source>SimpleX Chat security was audited by Trail of Bits.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Lock" xml:space="preserve">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"locale" : "ja"
|
||||
}
|
||||
],
|
||||
"properties" : {
|
||||
"localizable" : true
|
||||
},
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"red" : "0.000",
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "0.533"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"properties" : {
|
||||
"localizable" : true
|
||||
},
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/* Bundle display name */
|
||||
"CFBundleDisplayName" = "SimpleX NSE";
|
||||
/* Bundle name */
|
||||
"CFBundleName" = "SimpleX NSE";
|
||||
/* Copyright (human-readable) */
|
||||
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"`a + b`" = "\\`a + b`";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"~strike~" = "\\~strike~";
|
||||
|
||||
/* call status */
|
||||
"connecting call" = "connecting call…";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connecting server…" = "Connecting to server…";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"Connecting server… (error: %@)" = "Connecting to server… (error: %@)";
|
||||
|
||||
/* rcv group event chat item */
|
||||
"member connected" = "connected";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/* Bundle name */
|
||||
"CFBundleName" = "SimpleX";
|
||||
/* Privacy - Camera Usage Description */
|
||||
"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
|
||||
/* Privacy - Face ID Usage Description */
|
||||
"NSFaceIDUsageDescription" = "SimpleX uses Face ID for local authentication";
|
||||
/* Privacy - Microphone Usage Description */
|
||||
"NSMicrophoneUsageDescription" = "SimpleX needs microphone access for audio and video calls, and to record voice messages.";
|
||||
/* Privacy - Photo Library Additions Usage Description */
|
||||
"NSPhotoLibraryAddUsageDescription" = "SimpleX needs access to Photo Library for saving captured and received media";
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"developmentRegion" : "en",
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "ja",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "14C18",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "14.2"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -2501,8 +2501,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Show preview</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)." xml:space="preserve">
|
||||
<source>SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).</source>
|
||||
<trans-unit id="SimpleX Chat security was audited by Trail of Bits." xml:space="preserve">
|
||||
<source>SimpleX Chat security was audited by Trail of Bits.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Lock" xml:space="preserve">
|
||||
|
||||
@@ -2443,8 +2443,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Show preview</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)." xml:space="preserve">
|
||||
<source>SimpleX Chat security was [audited by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html).</source>
|
||||
<trans-unit id="SimpleX Chat security was audited by Trail of Bits." xml:space="preserve">
|
||||
<source>SimpleX Chat security was audited by Trail of Bits.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="SimpleX Lock" xml:space="preserve">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user