mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
398 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 920b56e3d8 | |||
| dd51f032d2 | |||
| 1bdbea4f6d | |||
| 90be54ff82 | |||
| bd4b445cbf | |||
| af98e703ec | |||
| fff8935b94 | |||
| 9e7a45c734 | |||
| af33f4e2d9 | |||
| 98e53fb35b | |||
| cb4aa29549 | |||
| 8fc3f5a0f7 | |||
| 80bd734cc1 | |||
| 65c6c63024 | |||
| f43fd57ec1 | |||
| 065b932e1f | |||
| 7ebb763889 | |||
| eacfc4aa8c | |||
| 9c49b038cd | |||
| b033fdbeee | |||
| 53dbe4b5d8 | |||
| 417eca74ad | |||
| d25ef4e1a1 | |||
| 5d775a63c6 | |||
| 576c886ba0 | |||
| 511e3586d9 | |||
| 7a166e46a9 | |||
| 87c35b037e | |||
| d63c7d2abc | |||
| ca5b3ddc0d | |||
| 4e2acbf456 | |||
| 202ecc369a | |||
| e5cec7a68b | |||
| 05b292ac00 | |||
| 562bd197bb | |||
| 94321cfc36 | |||
| 7b863ef459 | |||
| 1aedfd6e5a | |||
| 572e3b7d32 | |||
| ab708f8855 | |||
| f5612504f5 | |||
| 94e25d9bb4 | |||
| 369d411fc1 | |||
| 94312ec6fa | |||
| 4b652b62da | |||
| bf4df9ca58 | |||
| 61334d7b77 | |||
| 9a714a0926 | |||
| 7ddd300fe5 | |||
| 6b663baf10 | |||
| 048ada79bb | |||
| b69f422708 | |||
| 396abdbfab | |||
| 938bd56c3a | |||
| d3b5bbe566 | |||
| 1bd8f66730 | |||
| c2177f3684 | |||
| 72c0c61a86 | |||
| f594752bb1 | |||
| 4a3c9366fd | |||
| 6ca76ec8a9 | |||
| b089836efc | |||
| 90b616cd28 | |||
| 0f4473d272 | |||
| 43ceb184c4 | |||
| 2e5a0fca1a | |||
| 34c2303ef1 | |||
| ced69e431c | |||
| dcedbac379 | |||
| a6a87cb7de | |||
| 416ae400eb | |||
| b69916a3a3 | |||
| 62726e345c | |||
| 7a8db16791 | |||
| e24564d7d6 | |||
| ae17566a94 | |||
| c329bf4ea1 | |||
| 7fea9c85bd | |||
| 313d3a732d | |||
| 5d9b6266ea | |||
| c35ce29cc1 | |||
| 842bbf26c6 | |||
| ebc5242932 | |||
| be5e0d7f75 | |||
| 324a6ba38e | |||
| 7b67bc2d47 | |||
| 2f7ea909e2 | |||
| 9238ac3445 | |||
| 3bd5fc7463 | |||
| 4c33ed92bb | |||
| a0ae4125c5 | |||
| 34a60066fb | |||
| 1f50e94bc9 | |||
| 0e4376bada | |||
| b0ad94fe7f | |||
| fccd4f7ec4 | |||
| 3f93397031 | |||
| a5f8641d50 | |||
| f23c0b55f8 | |||
| 534151f1bb | |||
| 2ad9d0ddbc | |||
| 388bdc7083 | |||
| 3e370a7c16 | |||
| 30d4fc757c | |||
| 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 | |||
| af59178318 | |||
| 5149623b57 | |||
| 205c74b5d8 | |||
| 5116bfa79c | |||
| 69767126aa | |||
| 5af389ae3f | |||
| f711f4d8a8 | |||
| 8b80efd537 | |||
| 7b83450a9c | |||
| e3011a1cb0 | |||
| 7ff8dcfb78 | |||
| 551ed202be | |||
| 6f11913359 | |||
| d91a78da7d | |||
| 02fdd058ec | |||
| 08148afac7 | |||
| f037ffe107 | |||
| fa6ba3110b | |||
| 6f82ddc032 | |||
| ee3267388f | |||
| f97a1fcedf | |||
| 315d830357 | |||
| 00caeae914 | |||
| 199835b671 | |||
| ce2225d355 | |||
| b4f1f94bcc | |||
| 90cee6b802 | |||
| 2a883bb958 | |||
| 607f77d432 | |||
| c254b33753 | |||
| 59f3848056 | |||
| 69aa002c83 | |||
| 0b57cc08a7 | |||
| 8630d1ab12 | |||
| 591aa9eaa5 | |||
| f82fa42cba | |||
| aa441c88db | |||
| 17ee22da72 | |||
| a9957fb46d | |||
| f5c87fdd4c | |||
| 23467a2248 | |||
| 9fa93e40cb | |||
| f4b852d2dd | |||
| f698b7fa9f | |||
| b21afa648d | |||
| b9575cc869 | |||
| 3ea91bc4ad | |||
| b3dce5fdb0 | |||
| 28ad8b8cd5 | |||
| 37d4ef770c | |||
| ba24e40512 | |||
| 8097593f5e | |||
| 23ccd69b5e | |||
| 5e0d6d77b9 | |||
| a06393f520 | |||
| 549ffcefc0 | |||
| c8721e8000 | |||
| 03882367da | |||
| 4d700d113d | |||
| 17bdd2a1d2 | |||
| 80a68012a2 | |||
| 3742906f75 | |||
| ae90edcdb5 | |||
| 9e76aadb0f | |||
| 043544d7ec | |||
| 2bf7d1dddc | |||
| e1741118ce | |||
| 58fb3f7f2d | |||
| 48e92a7e9b | |||
| 5bf16da09d | |||
| 23ca3dd665 | |||
| 2caff25fa2 | |||
| 37f835be8c | |||
| a6c1f2f776 | |||
| acf0dfc38c | |||
| 67f13831ce | |||
| d50e65af20 | |||
| b8d74b74a6 | |||
| 802a28d759 | |||
| c85d43b349 | |||
| 64ad491197 | |||
| ddd8e719ef | |||
| e51f7a51cc | |||
| e66f5d488b | |||
| b386cc83a7 | |||
| 09481e09b6 | |||
| 6913bf1a46 | |||
| e6c87ff00b | |||
| 5f41cf3c52 | |||
| 9edbe2e589 | |||
| b6876712f0 | |||
| 5b4c183466 | |||
| 393238f47c | |||
| aea526f69d | |||
| 13cf1cc004 | |||
| 1766a8bb2c | |||
| 9a5732ab70 | |||
| 2923ca1356 | |||
| 96f0083384 | |||
| febfc396e3 | |||
| 29735a807b | |||
| eb36f64676 | |||
| 4e01970d69 | |||
| e5713087e3 | |||
| b40fc7ff18 | |||
| 06ad2b7972 | |||
| 14eeb4451c | |||
| ead67adeb8 | |||
| c2d70a5107 | |||
| 90dffc975a | |||
| e5ba7caddc | |||
| ec6cee1389 | |||
| 1d16a19373 | |||
| 04c90b4f07 | |||
| 747cbb8e09 | |||
| 834487e548 | |||
| e1b6b54209 | |||
| 6366d60e4c | |||
| fabea36682 | |||
| 991332a809 | |||
| 85537a99e8 | |||
| 717c90c58b |
@@ -0,0 +1,68 @@
|
||||
name: Bug
|
||||
description: File a bug report/issue
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Multiple selections are possible.
|
||||
multiple: true
|
||||
options:
|
||||
- Linux
|
||||
- Mac
|
||||
- Windows
|
||||
- Android
|
||||
- iOS
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: OS version
|
||||
description: Specify the OS version
|
||||
placeholder: ex. Android 12, Ubuntu 20.04
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: App version
|
||||
description: Specify the SimpleX version
|
||||
placeholder: ex. 4.3.2
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
placeholder: Bug happened!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
placeholder: No bug should happen!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
3. Click on ...
|
||||
4. See error...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Feature
|
||||
description: Suggest your feature
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Multiple selections are possible. If selected input is "all", this considered to be a general feature.
|
||||
multiple: true
|
||||
options:
|
||||
- Linux
|
||||
- Mac
|
||||
- Windows
|
||||
- Android
|
||||
- iOS
|
||||
- all
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: App version
|
||||
description: Specify the SimpleX version
|
||||
placeholder: ex. 4.3.2
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Feature
|
||||
description: Describe the feature you would like to see added
|
||||
placeholder: SimpleX Chat should make me coffee!
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Question
|
||||
description: Ask your question
|
||||
title: "[Q]: "
|
||||
labels: ["question", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Generally, we encourage you to ask questions in our [official group](https://simplex.chat/invitation/#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3Dsimplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D), but you can do it anyway :)
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Question
|
||||
description: Please ask your question in plain english.
|
||||
placeholder: Is SimpleX - chat?
|
||||
validations:
|
||||
required: true
|
||||
+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
|
||||
|
||||
+1
-1
@@ -49,8 +49,8 @@ logs/
|
||||
# for website
|
||||
website/node_modules/
|
||||
website/src/blog/
|
||||
website/src/docs/
|
||||
website/translations.json
|
||||
website/src/_data/supported_languages.json
|
||||
website/src/img/images/
|
||||
website/src/images/
|
||||
# Generated files
|
||||
|
||||
@@ -48,11 +48,34 @@
|
||||
|
||||
## Join user groups
|
||||
|
||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-2](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FQP8zaGjjmlXV-ix_Er4JgJ0lNPYGS1KX%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEApAgBkRZ3x12ayZ7sHrjHQWNMvqzZpWUgM_fFCUdLXwo%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xWpPXEZZsQp_F7vwAcAYDw%3D%3D%22%7D)
|
||||
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
|
||||
|
||||
You also can:
|
||||
- criticize the app, and make comparisons with other messengers.
|
||||
- share new messengers you think could be interesting for privacy, as long as you don't spam.
|
||||
- share some privacy related publications, infrequently.
|
||||
- having preliminary approved with the admin in direct message, share the link to a group you created.
|
||||
|
||||
You must:
|
||||
- be polite to other users
|
||||
- avoid spam (too frequent messages, even if they are relevant)
|
||||
- avoid any personal attacks or hostility.
|
||||
- avoid sharing any content that is not relevant to the above (that includes, but is not limited to, discussing politics or any aspects of society other than privacy, security, technology and communications, sharing any content that may be found offensive by other users, etc.).
|
||||
|
||||
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-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.
|
||||
|
||||
@@ -85,16 +108,20 @@ 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)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)|[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|
||||
Languages in progress: Arabic, Hindi, Japanese, Spanish and [many others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||
|
||||
## Contribute
|
||||
|
||||
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.
|
||||
@@ -180,6 +207,12 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent updates:
|
||||
|
||||
[July 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
||||
|
||||
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
|
||||
|
||||
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||
|
||||
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||
|
||||
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
@@ -226,13 +259,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.
|
||||
@@ -257,6 +292,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.
|
||||
@@ -294,26 +331,30 @@ If you are considering developing with SimpleX platform please get in touch for
|
||||
- ✅ Improved audio & video calls.
|
||||
- ✅ Support older Android OS and 32-bit CPUs.
|
||||
- ✅ Hidden chat profiles.
|
||||
- 🏗 Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
- 🏗 Video messages.
|
||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||
- 🏗 Reduced battery and traffic usage in large groups.
|
||||
- ✅ 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.
|
||||
- ✅ Optional alternative access password.
|
||||
- ✅ Message reactions
|
||||
- ✅ Message editing history
|
||||
- ✅ Reduced battery and traffic usage in large groups.
|
||||
- ✅ Message delivery confirmation (with sender opt-out per contact).
|
||||
- 🏗 Desktop client.
|
||||
- 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.
|
||||
- Access password/pin (with 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,223 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 32
|
||||
|
||||
defaultConfig {
|
||||
applicationId "chat.simplex.app"
|
||||
minSdk 26
|
||||
targetSdk 32
|
||||
versionCode 111
|
||||
versionName "4.6.1-beta.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags ''
|
||||
}
|
||||
}
|
||||
manifestPlaceholders.app_name = "@string/app_name"
|
||||
manifestPlaceholders.provider_authorities = "chat.simplex.app.provider"
|
||||
manifestPlaceholders.extract_native_libs = compression_level != "0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix "$application_id_suffix"
|
||||
debuggable new Boolean("$enable_debuggable")
|
||||
manifestPlaceholders.app_name = "$app_name"
|
||||
// Provider can't be the same for different apps on the same device
|
||||
manifestPlaceholders.provider_authorities = "chat.simplex.app${application_id_suffix}.provider"
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi"
|
||||
freeCompilerArgs += "-opt-in=androidx.compose.material.ExperimentalMaterialApi"
|
||||
freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets"
|
||||
freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi"
|
||||
freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi"
|
||||
freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file('src/main/cpp/CMakeLists.txt')
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion compose_version
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
jniLibs.useLegacyPackaging = compression_level != "0"
|
||||
}
|
||||
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||
def isBundle = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("bundle") }) != null
|
||||
// if (isRelease) {
|
||||
// Comma separated list of languages that will be included in the apk
|
||||
android.defaultConfig.resConfigs("en", "cs", "de", "es", "fr", "it", "nl", "ru", "zh-rCN")
|
||||
// }
|
||||
if (isBundle) {
|
||||
defaultConfig.ndk.abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||
} else {
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
if (isRelease) {
|
||||
include 'arm64-v8a', 'armeabi-v7a'
|
||||
} else {
|
||||
include 'arm64-v8a', 'armeabi-v7a'
|
||||
universalApk false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation "androidx.compose.ui:ui:$compose_version"
|
||||
implementation "androidx.compose.material:material:$compose_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
|
||||
implementation 'androidx.activity:activity-compose:1.4.0'
|
||||
implementation 'androidx.fragment:fragment:1.4.1'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||
implementation "androidx.compose.ui:ui-util:$compose_version"
|
||||
implementation "androidx.navigation:navigation-compose:2.4.1"
|
||||
implementation "com.google.accompanist:accompanist-insets:0.23.0"
|
||||
implementation 'androidx.webkit:webkit:1.4.0'
|
||||
implementation "com.godaddy.android.colorpicker:compose-color-picker:0.4.2"
|
||||
|
||||
def work_version = "2.7.1"
|
||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||
implementation "androidx.work:work-multiprocess:$work_version"
|
||||
|
||||
def camerax_version = "1.1.0-beta01"
|
||||
implementation "androidx.camera:camera-core:${camerax_version}"
|
||||
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
||||
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
||||
implementation "androidx.camera:camera-view:${camerax_version}"
|
||||
|
||||
//Barcode
|
||||
implementation 'org.boofcv:boofcv-android:0.40.1'
|
||||
implementation 'org.boofcv:boofcv-core:0.40.1'
|
||||
|
||||
//Camera Permission
|
||||
implementation "com.google.accompanist:accompanist-permissions:0.23.0"
|
||||
implementation "com.google.accompanist:accompanist-pager:0.25.1"
|
||||
|
||||
// Link Previews
|
||||
implementation 'org.jsoup:jsoup:1.13.1'
|
||||
|
||||
// Biometric authentication
|
||||
implementation 'androidx.biometric:biometric:1.2.0-alpha04'
|
||||
|
||||
// GIFs support
|
||||
implementation "io.coil-kt:coil-compose:2.1.0"
|
||||
implementation "io.coil-kt:coil-gif:2.1.0"
|
||||
|
||||
// Video support
|
||||
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||
}
|
||||
|
||||
// Don't do anything if no compression is needed
|
||||
if (compression_level != "0") {
|
||||
tasks.whenTaskAdded { task ->
|
||||
if (task.name == 'packageDebug') {
|
||||
task.finalizedBy compressApk
|
||||
} else if (task.name == 'packageRelease') {
|
||||
task.finalizedBy compressApk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("compressApk") {
|
||||
doLast {
|
||||
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||
def buildType
|
||||
if (isRelease) {
|
||||
buildType = "release"
|
||||
} else {
|
||||
buildType = "debug"
|
||||
}
|
||||
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
||||
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
||||
def keyAlias = ""
|
||||
def keyPassword = ""
|
||||
def storeFile = ""
|
||||
def storePassword = ""
|
||||
if (project.properties['android.injected.signing.key.alias'] != null) {
|
||||
keyAlias = project.properties['android.injected.signing.key.alias']
|
||||
keyPassword = project.properties['android.injected.signing.key.password']
|
||||
storeFile = project.properties['android.injected.signing.store.file']
|
||||
storePassword = project.properties['android.injected.signing.store.password']
|
||||
} else if (android.signingConfigs.hasProperty(buildType)) {
|
||||
def gradleConfig = android.signingConfigs[buildType]
|
||||
keyAlias = gradleConfig.keyAlias
|
||||
keyPassword = gradleConfig.keyPassword
|
||||
storeFile = gradleConfig.storeFile
|
||||
storePassword = gradleConfig.storePassword
|
||||
} else {
|
||||
// There is no signing config for current build type, can't sign the apk
|
||||
println("No signing configs for this build type: $buildType")
|
||||
return
|
||||
}
|
||||
|
||||
def outputDir = tasks["package${buildType.capitalize()}"].outputs.files.last()
|
||||
|
||||
exec {
|
||||
workingDir '../../../scripts/android'
|
||||
setEnvironment(['JAVA_HOME': "$javaHome"])
|
||||
commandLine './compress-and-sign-apk.sh', \
|
||||
"$compression_level", \
|
||||
"$outputDir", \
|
||||
"$sdkDir", \
|
||||
"$storeFile", \
|
||||
"$storePassword", \
|
||||
"$keyAlias", \
|
||||
"$keyPassword"
|
||||
}
|
||||
|
||||
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
||||
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||
new File(outputDir, "app-armeabi-v7a-release.apk").renameTo(new File(outputDir, "simplex-armv7a.apk"))
|
||||
new File(outputDir, "app-arm64-v8a-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||
}
|
||||
|
||||
// View all gradle properties set
|
||||
// project.properties.each { k, v -> println "$k -> $v" }
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.SimplexApp
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
enum class DefaultTheme {
|
||||
SYSTEM, DARK, LIGHT
|
||||
}
|
||||
|
||||
val DEFAULT_PADDING = 16.dp
|
||||
val DEFAULT_SPACE_AFTER_ICON = 4.dp
|
||||
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
|
||||
val DEFAULT_BOTTOM_PADDING = 48.dp
|
||||
|
||||
val DarkColorPalette = darkColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = DarkGray,
|
||||
// background = Color.Black,
|
||||
// surface = Color.Black,
|
||||
// background = Color(0xFF121212),
|
||||
// surface = Color(0xFF121212),
|
||||
// error = Color(0xFFCF6679),
|
||||
onBackground = Color(0xFFFFFBFA),
|
||||
onSurface = Color(0xFFFFFBFA),
|
||||
// onError: Color = Color.Black,
|
||||
)
|
||||
val LightColorPalette = lightColors(
|
||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||
primaryVariant = SimplexGreen,
|
||||
secondary = LightGray,
|
||||
// background = Color.White,
|
||||
// surface = Color.White
|
||||
// onPrimary = Color.White,
|
||||
// onSecondary = Color.Black,
|
||||
// onBackground = Color.Black,
|
||||
// onSurface = Color.Black,
|
||||
)
|
||||
|
||||
val CurrentColors: MutableStateFlow<Pair<Colors, DefaultTheme>> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
|
||||
|
||||
// Non-@Composable implementation
|
||||
private fun isInNightMode() =
|
||||
(SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
|
||||
|
||||
@Composable
|
||||
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.first.isLight
|
||||
|
||||
@Composable
|
||||
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
||||
LaunchedEffect(darkTheme) {
|
||||
// For preview
|
||||
if (darkTheme != null)
|
||||
CurrentColors.value = ThemeManager.currentColors(darkTheme)
|
||||
}
|
||||
val systemDark = isSystemInDarkTheme()
|
||||
LaunchedEffect(systemDark) {
|
||||
if (CurrentColors.value.second == DefaultTheme.SYSTEM && CurrentColors.value.first.isLight == systemDark) {
|
||||
// Change active colors from light to dark and back based on system theme
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
|
||||
}
|
||||
}
|
||||
val theme by CurrentColors.collectAsState()
|
||||
MaterialTheme(
|
||||
colors = theme.first,
|
||||
typography = Typography,
|
||||
shapes = Shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package chat.simplex.app.ui.theme
|
||||
|
||||
import androidx.compose.material.Colors
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.AppPreferences
|
||||
import chat.simplex.app.views.helpers.generalGetString
|
||||
|
||||
object ThemeManager {
|
||||
private val appPrefs: AppPreferences by lazy {
|
||||
AppPreferences(SimplexApp.context)
|
||||
}
|
||||
|
||||
fun currentColors(darkForSystemTheme: Boolean): Pair<Colors, DefaultTheme> {
|
||||
val theme = appPrefs.currentTheme.get()!!
|
||||
val systemThemeColors = if (darkForSystemTheme) DarkColorPalette else LightColorPalette
|
||||
val res = when (theme) {
|
||||
DefaultTheme.SYSTEM.name -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
|
||||
DefaultTheme.DARK.name -> Pair(DarkColorPalette, DefaultTheme.DARK)
|
||||
DefaultTheme.LIGHT.name -> Pair(LightColorPalette, DefaultTheme.LIGHT)
|
||||
else -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
|
||||
}
|
||||
return res.copy(first = res.first.copy(primary = Color(appPrefs.primaryColor.get())))
|
||||
}
|
||||
|
||||
// colors, default theme enum, localized name of theme
|
||||
fun allThemes(darkForSystemTheme: Boolean): List<Triple<Colors, DefaultTheme, String>> {
|
||||
val allThemes = ArrayList<Triple<Colors, DefaultTheme, String>>()
|
||||
allThemes.add(
|
||||
Triple(
|
||||
if (darkForSystemTheme) DarkColorPalette else LightColorPalette,
|
||||
DefaultTheme.SYSTEM,
|
||||
generalGetString(R.string.theme_system)
|
||||
)
|
||||
)
|
||||
allThemes.add(
|
||||
Triple(
|
||||
LightColorPalette,
|
||||
DefaultTheme.LIGHT,
|
||||
generalGetString(R.string.theme_light)
|
||||
)
|
||||
)
|
||||
allThemes.add(
|
||||
Triple(
|
||||
DarkColorPalette,
|
||||
DefaultTheme.DARK,
|
||||
generalGetString(R.string.theme_dark)
|
||||
)
|
||||
)
|
||||
return allThemes
|
||||
}
|
||||
|
||||
fun applyTheme(name: String, darkForSystemTheme: Boolean) {
|
||||
appPrefs.currentTheme.set(name)
|
||||
CurrentColors.value = currentColors(darkForSystemTheme)
|
||||
}
|
||||
|
||||
fun saveAndApplyPrimaryColor(color: Color) {
|
||||
appPrefs.primaryColor.set(color.toArgb())
|
||||
CurrentColors.value = currentColors(!CurrentColors.value.first.isLight)
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package chat.simplex.app.views
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBackIosNew
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.Profile
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.AppBarTitle
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||
import chat.simplex.app.views.onboarding.ReadableText
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
fun isValidDisplayName(name: String) : Boolean {
|
||||
return (name.firstOrNull { it.isWhitespace() }) == null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||
val displayName = remember { mutableStateOf("") }
|
||||
val fullName = remember { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.create_profile), false)
|
||||
ReadableText(R.string.your_profile_is_stored_on_your_device)
|
||||
ReadableText(R.string.profile_is_only_shared_with_your_contacts)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
stringResource(R.string.display_name),
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(bottom = 3.dp)
|
||||
)
|
||||
ProfileNameField(displayName, focusRequester)
|
||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
||||
Text(
|
||||
errorText,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colors.error
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
stringResource(R.string.full_name_optional__prompt),
|
||||
style = MaterialTheme.typography.h6,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
ProfileNameField(fullName)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
if (chatModel.users.isEmpty()) {
|
||||
SimpleButton(
|
||||
text = stringResource(R.string.about_simplex),
|
||||
icon = Icons.Outlined.ArrowBackIosNew
|
||||
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
|
||||
}
|
||||
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
val createModifier: Modifier
|
||||
val createColor: Color
|
||||
if (enabled) {
|
||||
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp)
|
||||
createColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
createModifier = Modifier.padding(8.dp)
|
||||
createColor = HighOrLowlight
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
||||
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor)
|
||||
Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
|
||||
withApi {
|
||||
val user = chatModel.controller.apiCreateActiveUser(
|
||||
Profile(displayName, fullName, null)
|
||||
) ?: return@withApi
|
||||
chatModel.currentUser.value = user
|
||||
if (chatModel.users.isEmpty()) {
|
||||
chatModel.controller.startChat(user)
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
|
||||
SimplexApp.context.chatModel.controller.ntfManager.createNtfChannelsMaybeShowAlert()
|
||||
} else {
|
||||
val users = chatModel.controller.listUsers()
|
||||
chatModel.users.clear()
|
||||
chatModel.users.addAll(users)
|
||||
chatModel.controller.getUserChatData()
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileNameField(name: MutableState<String>, focusRequester: FocusRequester? = null) {
|
||||
val modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.secondary)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.padding(8.dp)
|
||||
.navigationBarsWithImePadding()
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(HighOrLowlight)
|
||||
)
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import InfoRow
|
||||
import InfoRowEllipsis
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun ChatInfoView(
|
||||
chatModel: ChatModel,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
connectionCode: String?,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
if (chat != null) {
|
||||
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
|
||||
mutableStateOf(chatModel.contactNetworkStatus(contact))
|
||||
}
|
||||
ChatInfoLayout(
|
||||
chat,
|
||||
contact,
|
||||
connStats,
|
||||
contactNetworkStatus.value,
|
||||
customUserProfile,
|
||||
localAlias,
|
||||
connectionCode,
|
||||
developerTools,
|
||||
onLocalAliasChanged = {
|
||||
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
||||
},
|
||||
openPreferences = {
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
ContactPreferencesView(chatModel, user, contact.contactId, close)
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||
switchContactAddress = {
|
||||
showSwitchContactAddressAlert(chatModel, contact.contactId)
|
||||
},
|
||||
verifyClicked = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
|
||||
VerifyCodeView(
|
||||
ct.displayName,
|
||||
connectionCode,
|
||||
ct.verified,
|
||||
verify = { code ->
|
||||
chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r ->
|
||||
val (verified, existingCode) = r
|
||||
chatModel.updateContact(
|
||||
ct.copy(
|
||||
activeConn = ct.activeConn.copy(
|
||||
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||
)
|
||||
)
|
||||
)
|
||||
r
|
||||
}
|
||||
},
|
||||
close,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.delete_contact_question),
|
||||
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (r) {
|
||||
chatModel.removeChat(chatInfo.id)
|
||||
chatModel.chatId.value = null
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.clear_chat_question),
|
||||
text = generalGetString(R.string.clear_chat_warning),
|
||||
confirmText = generalGetString(R.string.clear_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (updatedChatInfo != null) {
|
||||
chatModel.clearChat(updatedChatInfo)
|
||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoLayout(
|
||||
chat: Chat,
|
||||
contact: Contact,
|
||||
connStats: ConnectionStats?,
|
||||
contactNetworkStatus: NetworkStatus,
|
||||
customUserProfile: Profile?,
|
||||
localAlias: String,
|
||||
connectionCode: String?,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
openPreferences: () -> Unit,
|
||||
deleteContact: () -> Unit,
|
||||
clearChat: () -> Unit,
|
||||
switchContactAddress: () -> Unit,
|
||||
verifyClicked: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
ChatInfoHeader(chat.chatInfo, contact)
|
||||
}
|
||||
|
||||
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
|
||||
|
||||
if (customUserProfile != null) {
|
||||
SectionSpacer()
|
||||
SectionView(generalGetString(R.string.incognito).uppercase()) {
|
||||
InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
|
||||
}
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(contact.verified, verifyClicked)
|
||||
SectionDivider()
|
||||
}
|
||||
ContactPreferencesButton(openPreferences)
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
SwitchAddressButton(switchContactAddress)
|
||||
SectionDivider()
|
||||
if (connStats != null) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.network_status),
|
||||
contactNetworkStatus.statusExplanation
|
||||
)}) {
|
||||
NetworkStatusRow(contactNetworkStatus)
|
||||
}
|
||||
val rcvServers = connStats.rcvServers
|
||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||
}
|
||||
val sndServers = connStats.sndServers
|
||||
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
ClearChatButton(clearChat)
|
||||
SectionDivider()
|
||||
DeleteContactButton(deleteContact)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (developerTools) {
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
|
||||
SectionDivider()
|
||||
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (contact.verified) {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
|
||||
}
|
||||
Text(
|
||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LocalAliasEditor(
|
||||
initialValue: String,
|
||||
center: Boolean = true,
|
||||
leadingIcon: Boolean = false,
|
||||
focus: Boolean = false,
|
||||
updateValue: (String) -> Unit
|
||||
) {
|
||||
var value by rememberSaveable { mutableStateOf(initialValue) }
|
||||
val modifier = if (center)
|
||||
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp)
|
||||
else
|
||||
Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).fillMaxWidth()
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (center) Arrangement.Center else Arrangement.Start) {
|
||||
DefaultBasicTextField(
|
||||
modifier,
|
||||
value,
|
||||
{
|
||||
Text(
|
||||
generalGetString(R.string.text_field_set_contact_placeholder),
|
||||
textAlign = if (center) TextAlign.Center else TextAlign.Start,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
},
|
||||
leadingIcon = if (leadingIcon) {
|
||||
{ Icon(Icons.Default.Edit, null, Modifier.padding(start = 7.dp)) }
|
||||
} else null,
|
||||
color = HighOrLowlight,
|
||||
focus = focus,
|
||||
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
|
||||
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
|
||||
) {
|
||||
value = it
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { value }
|
||||
.onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing
|
||||
.conflate() // get the latest value
|
||||
.filter { it == value } // don't process old ones
|
||||
.collect {
|
||||
updateValue(value)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.network_status))
|
||||
Icon(
|
||||
Icons.Outlined.Info,
|
||||
stringResource(R.string.network_status),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
networkStatus.statusString,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
ServerImage(networkStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerImage(networkStatus: NetworkStatus) {
|
||||
Box(Modifier.size(18.dp)) {
|
||||
when (networkStatus) {
|
||||
is NetworkStatus.Connected ->
|
||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
||||
is NetworkStatus.Disconnected ->
|
||||
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
|
||||
is NetworkStatus.Error ->
|
||||
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
|
||||
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimplexServers(text: String, servers: List<String>) {
|
||||
val info = servers.joinToString(separator = ", ") { it.substringAfter("@") }
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
InfoRowEllipsis(text, info) {
|
||||
clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ",")))
|
||||
Toast.makeText(SimplexApp.context, generalGetString(R.string.copied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwitchAddressButton(onClick: () -> Unit) {
|
||||
SectionItemView(onClick) {
|
||||
Text(stringResource(R.string.switch_receiving_address), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
if (contactVerified) Icons.Outlined.VerifiedUser else Icons.Outlined.Shield,
|
||||
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
|
||||
click = onClick,
|
||||
iconColor = HighOrLowlight,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.contact_preferences),
|
||||
click = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearChatButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Restore,
|
||||
stringResource(R.string.clear_chat_button),
|
||||
click = onClick,
|
||||
textColor = WarningOrange,
|
||||
iconColor = WarningOrange,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteContactButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_delete_contact),
|
||||
click = onClick,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
}
|
||||
|
||||
private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel) = withApi {
|
||||
chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let {
|
||||
chatModel.updateContact(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSwitchContactAddressAlert(m: ChatModel, contactId: Long) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.switch_receiving_address_question),
|
||||
text = generalGetString(R.string.switch_receiving_address_desc),
|
||||
confirmText = generalGetString(R.string.switch_verb),
|
||||
onConfirm = {
|
||||
switchContactAddress(m, contactId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun switchContactAddress(m: ChatModel, contactId: Long) = withApi {
|
||||
m.controller.apiSwitchContact(contactId)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatInfoLayout() {
|
||||
SimpleXTheme {
|
||||
ChatInfoLayout(
|
||||
chat = Chat(
|
||||
chatInfo = ChatInfo.Direct.sampleData,
|
||||
chatItems = arrayListOf()
|
||||
),
|
||||
Contact.sampleData,
|
||||
localAlias = "",
|
||||
connectionCode = "123",
|
||||
developerTools = false,
|
||||
connStats = null,
|
||||
contactNetworkStatus = NetworkStatus.Connected(),
|
||||
onLocalAliasChanged = {},
|
||||
customUserProfile = null,
|
||||
openPreferences = {},
|
||||
deleteContact = {},
|
||||
clearChat = {},
|
||||
switchContactAddress = {},
|
||||
verifyClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING_HALF
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||
|
||||
@Composable
|
||||
fun ComposeImageView(images: List<String>, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LazyRow(
|
||||
Modifier.weight(1f).padding(start = DEFAULT_PADDING_HALF, end = if (cancelEnabled) 0.dp else DEFAULT_PADDING_HALF),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF),
|
||||
) {
|
||||
items(images.size) { index ->
|
||||
val imageBitmap = base64ToBitmap(images[index]).asImageBitmap()
|
||||
Image(
|
||||
imageBitmap,
|
||||
"preview image",
|
||||
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (cancelEnabled) {
|
||||
IconButton(onClick = cancelImages) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
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.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.chat.item.SentColorLight
|
||||
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)
|
||||
)
|
||||
Row(
|
||||
Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(SentColorLight),
|
||||
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) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
tint = if (finishedRecording) MaterialTheme.colors.primary else HighOrLowlight
|
||||
)
|
||||
}
|
||||
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 = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (cancelEnabled) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
AudioPlayer.stop(filePath)
|
||||
cancelVoice()
|
||||
},
|
||||
modifier = Modifier.padding(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.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
|
||||
)
|
||||
}
|
||||
}
|
||||
-360
@@ -1,360 +0,0 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import InfoRow
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoView(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
connectionCode: String?,
|
||||
chatModel: ChatModel,
|
||||
close: () -> Unit,
|
||||
closeAll: () -> Unit, // Close all open windows up to ChatView
|
||||
) {
|
||||
BackHandler(onBack = close)
|
||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
if (chat != null) {
|
||||
val newRole = remember { mutableStateOf(member.memberRole) }
|
||||
GroupMemberInfoLayout(
|
||||
groupInfo,
|
||||
member,
|
||||
connStats,
|
||||
newRole,
|
||||
developerTools,
|
||||
connectionCode,
|
||||
getContactChat = { chatModel.getContactChat(it) },
|
||||
openDirectChat = {
|
||||
withApi {
|
||||
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||
if (c != null) {
|
||||
if (chatModel.getContactChat(it) == null) {
|
||||
chatModel.addChat(c)
|
||||
}
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItems.addAll(c.chatItems)
|
||||
chatModel.chatId.value = c.id
|
||||
closeAll()
|
||||
}
|
||||
}
|
||||
},
|
||||
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
|
||||
onRoleSelected = {
|
||||
if (it == newRole.value) return@GroupMemberInfoLayout
|
||||
val prevValue = newRole.value
|
||||
newRole.value = it
|
||||
updateMemberRoleDialog(it, member, onDismiss = {
|
||||
newRole.value = prevValue
|
||||
}) {
|
||||
withApi {
|
||||
kotlin.runCatching {
|
||||
val mem = chatModel.controller.apiMemberRole(groupInfo.groupId, member.groupMemberId, it)
|
||||
chatModel.upsertGroupMember(groupInfo, mem)
|
||||
}.onFailure {
|
||||
newRole.value = prevValue
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
switchMemberAddress = {
|
||||
switchMemberAddress(chatModel, groupInfo, member)
|
||||
},
|
||||
verifyClicked = {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||
VerifyCodeView(
|
||||
mem.displayName,
|
||||
connectionCode,
|
||||
mem.verified,
|
||||
verify = { code ->
|
||||
chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r ->
|
||||
val (verified, existingCode) = r
|
||||
chatModel.upsertGroupMember(
|
||||
groupInfo,
|
||||
mem.copy(
|
||||
activeConn = mem.activeConn?.copy(
|
||||
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||
)
|
||||
)
|
||||
)
|
||||
r
|
||||
}
|
||||
},
|
||||
close,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.button_remove_member),
|
||||
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
|
||||
confirmText = generalGetString(R.string.remove_member_confirmation),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId)
|
||||
if (removedMember != null) {
|
||||
chatModel.upsertGroupMember(groupInfo, removedMember)
|
||||
}
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoLayout(
|
||||
groupInfo: GroupInfo,
|
||||
member: GroupMember,
|
||||
connStats: ConnectionStats?,
|
||||
newRole: MutableState<GroupMemberRole>,
|
||||
developerTools: Boolean,
|
||||
connectionCode: String?,
|
||||
getContactChat: (Long) -> Chat?,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
removeMember: () -> Unit,
|
||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||
switchMemberAddress: () -> Unit,
|
||||
verifyClicked: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
GroupMemberInfoHeader(member)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
if (member.memberActive) {
|
||||
val contactId = member.memberContactId
|
||||
if (contactId != null) {
|
||||
SectionView {
|
||||
val chat = getContactChat(contactId)
|
||||
if ((chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) || groupInfo.fullGroupPreferences.directMessages.on) {
|
||||
OpenChatButton(onClick = { openDirectChat(contactId) })
|
||||
if (connectionCode != null) {
|
||||
SectionDivider()
|
||||
}
|
||||
}
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(member.verified, verifyClicked)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
||||
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
||||
SectionDivider()
|
||||
val roles = remember { member.canChangeRoleTo(groupInfo) }
|
||||
if (roles != null) {
|
||||
SectionItemView {
|
||||
RoleSelectionRow(roles, newRole, onRoleSelected)
|
||||
}
|
||||
} else {
|
||||
InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
|
||||
}
|
||||
val conn = member.activeConn
|
||||
if (conn != null) {
|
||||
SectionDivider()
|
||||
val connLevelDesc =
|
||||
if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
|
||||
else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
|
||||
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
if (connStats != null) {
|
||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||
SwitchAddressButton(switchMemberAddress)
|
||||
SectionDivider()
|
||||
val rcvServers = connStats.rcvServers
|
||||
val sndServers = connStats.sndServers
|
||||
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
|
||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||
SectionDivider()
|
||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||
}
|
||||
} else if (sndServers != null && sndServers.isNotEmpty()) {
|
||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
if (member.canBeRemoved(groupInfo)) {
|
||||
SectionView {
|
||||
RemoveMemberButton(removeMember)
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
|
||||
if (developerTools) {
|
||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
|
||||
SectionDivider()
|
||||
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
|
||||
}
|
||||
SectionSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupMemberInfoHeader(member: GroupMember) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (member.verified) {
|
||||
Icon(Icons.Outlined.VerifiedUser, null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = HighOrLowlight)
|
||||
}
|
||||
Text(
|
||||
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (member.fullName != "" && member.fullName != member.displayName) {
|
||||
Text(
|
||||
member.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoveMemberButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.button_remove_member),
|
||||
click = onClick,
|
||||
textColor = Color.Red,
|
||||
iconColor = Color.Red,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpenChatButton(onClick: () -> Unit) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.Message,
|
||||
stringResource(R.string.button_send_direct_message),
|
||||
click = onClick,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoleSelectionRow(
|
||||
roles: List<GroupMemberRole>,
|
||||
selectedRole: MutableState<GroupMemberRole>,
|
||||
onSelected: (GroupMemberRole) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
val values = remember { roles.map { it to it.text } }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.change_role),
|
||||
values,
|
||||
selectedRole,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMemberRoleDialog(
|
||||
newRole: GroupMemberRole,
|
||||
member: GroupMember,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.change_member_role_question),
|
||||
text = if (member.memberCurrent)
|
||||
String.format(generalGetString(R.string.member_role_will_be_changed_with_notification), newRole.text)
|
||||
else
|
||||
String.format(generalGetString(R.string.member_role_will_be_changed_with_invitation), newRole.text),
|
||||
confirmText = generalGetString(R.string.change_verb),
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = onConfirm,
|
||||
onDismissRequest = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
private fun switchMemberAddress(m: ChatModel, groupInfo: GroupInfo, member: GroupMember) = withApi {
|
||||
m.controller.apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewGroupMemberInfoLayout() {
|
||||
SimpleXTheme {
|
||||
GroupMemberInfoLayout(
|
||||
groupInfo = GroupInfo.sampleData,
|
||||
member = GroupMember.sampleData,
|
||||
connStats = null,
|
||||
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
||||
developerTools = false,
|
||||
connectionCode = "123",
|
||||
getContactChat = { Chat.sampleData },
|
||||
openDirectChat = {},
|
||||
removeMember = {},
|
||||
onRoleSelected = {},
|
||||
switchMemberAddress = {},
|
||||
verifyClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
package chat.simplex.app.views.chat.group
|
||||
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
|
||||
var groupInfo by remember { mutableStateOf(groupInfo) }
|
||||
val welcomeText = remember { mutableStateOf(groupInfo.groupProfile.description ?: "") }
|
||||
|
||||
fun save(afterSave: () -> Unit = {}) {
|
||||
withApi {
|
||||
var welcome: String? = welcomeText.value.trim('\n', ' ')
|
||||
if (welcome?.length == 0) {
|
||||
welcome = null
|
||||
}
|
||||
val groupProfileUpdated = groupInfo.groupProfile.copy(description = welcome)
|
||||
val res = m.controller.apiUpdateGroup(groupInfo.groupId, groupProfileUpdated)
|
||||
if (res != null) {
|
||||
groupInfo = res
|
||||
m.updateGroup(res)
|
||||
welcomeText.value = welcome ?: ""
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
}
|
||||
|
||||
ModalView(
|
||||
close = {
|
||||
if (welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)) close()
|
||||
else showUnsavedChangesAlert({ save(close) }, close)
|
||||
},
|
||||
background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight
|
||||
) {
|
||||
GroupWelcomeLayout(
|
||||
welcomeText,
|
||||
groupInfo,
|
||||
save = ::save
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupWelcomeLayout(
|
||||
welcomeText: MutableState<String>,
|
||||
groupInfo: GroupInfo,
|
||||
save: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.group_welcome_title))
|
||||
val welcomeText = remember { welcomeText }
|
||||
TextEditor(Modifier.padding(horizontal = DEFAULT_PADDING).height(160.dp), text = welcomeText)
|
||||
SectionSpacer()
|
||||
SaveButton(
|
||||
save = save,
|
||||
disabled = welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveButton(save: () -> Unit, disabled: Boolean) {
|
||||
SectionView {
|
||||
SectionItemView(save, disabled = disabled) {
|
||||
Text(stringResource(R.string.save_and_update_group_profile), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialogStacked(
|
||||
title = generalGetString(R.string.save_welcome_message_question),
|
||||
confirmText = generalGetString(R.string.save_and_update_group_profile),
|
||||
dismissText = generalGetString(R.string.exit_without_saving),
|
||||
onConfirm = save,
|
||||
onDismiss = revert,
|
||||
)
|
||||
}
|
||||
@@ -1,446 +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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.*
|
||||
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.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
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() {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
if (cItem.meta.itemDeleted == null && !live) {
|
||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.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), Icons.Outlined.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), Icons.Outlined.ContentCopy, 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), Icons.Outlined.SaveAlt, 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), Icons.Filled.Edit, onClick = {
|
||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
if (cItem.meta.itemDeleted != null && revealed.value) {
|
||||
ItemAction(
|
||||
stringResource(R.string.hide_verb),
|
||||
Icons.Outlined.VisibilityOff,
|
||||
onClick = {
|
||||
revealed.value = false
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancellable) {
|
||||
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile)
|
||||
}
|
||||
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() {
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
if (!cItem.isDeletedContent) {
|
||||
ItemAction(
|
||||
stringResource(R.string.reveal_verb),
|
||||
Icons.Outlined.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)
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
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(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, HighOrLowlight, 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
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.cancel_verb),
|
||||
Icons.Outlined.Close,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
cancelFileAlertDialog(fileId, cancelFile = cancelFile)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItemAction(
|
||||
cItem: ChatItem,
|
||||
showMenu: MutableState<Boolean>,
|
||||
questionText: String,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(R.string.delete_verb),
|
||||
Icons.Outlined.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),
|
||||
Icons.Outlined.Flag,
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
|
||||
DropdownMenuItem(onClick) {
|
||||
Row {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = color
|
||||
)
|
||||
Icon(icon, text, tint = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(R.string.cancel_file__question),
|
||||
text = generalGetString(R.string.file_transfer_will_be_cancelled_warning),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
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.End,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(R.string.for_me_only)) }
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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,179 +0,0 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
class AlertManager {
|
||||
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
|
||||
|
||||
fun showAlert(alert: @Composable () -> Unit) {
|
||||
Log.d(TAG, "AlertManager.showAlert")
|
||||
alertViews.add(alert)
|
||||
}
|
||||
|
||||
fun hideAlert() {
|
||||
alertViews.removeLastOrNull()
|
||||
}
|
||||
|
||||
fun showAlertDialogButtons(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
buttons = buttons
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialogButtonsColumn(
|
||||
title: String,
|
||||
text: AnnotatedString? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
showAlert {
|
||||
Dialog(onDismissRequest = this::hideAlert) {
|
||||
Column(Modifier.background(MaterialTheme.colors.background, MaterialTheme.shapes.medium)) {
|
||||
Text(title,
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING, bottom = if (text == null) DEFAULT_PADDING else DEFAULT_PADDING_HALF),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (text != null) {
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||
Text(
|
||||
text,
|
||||
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialog(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok),
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
onDismissRequest: (() -> Unit)? = null,
|
||||
destructive: Boolean = false
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertDialogStacked(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok),
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
onDismissRequest: (() -> Unit)? = null,
|
||||
destructive: Boolean = false
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
buttons = {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp).padding(top = 16.dp, bottom = 2.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
onDismiss?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(dismissText) }
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertMsg(
|
||||
title: String, text: String? = null,
|
||||
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
|
||||
) {
|
||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = this::hideAlert,
|
||||
title = { Text(title) },
|
||||
text = alertText,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onConfirm?.invoke()
|
||||
hideAlert()
|
||||
}) { Text(confirmText) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlertMsg(
|
||||
title: Int,
|
||||
text: Int? = null,
|
||||
confirmText: Int = R.string.ok,
|
||||
onConfirm: (() -> Unit)? = null
|
||||
) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText), onConfirm)
|
||||
|
||||
@Composable
|
||||
fun showInView() {
|
||||
remember { alertViews }.lastOrNull()?.invoke()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared = AlertManager()
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.newchat.ActionButton
|
||||
|
||||
sealed class AttachmentOption {
|
||||
object TakePhoto: AttachmentOption()
|
||||
object PickImage: AttachmentOption()
|
||||
object PickVideo: AttachmentOption()
|
||||
object PickFile: AttachmentOption()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseAttachmentView(
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
allowVideoAttachment: Boolean,
|
||||
hide: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.onFocusChanged { focusState ->
|
||||
if (!focusState.hasFocus) hide()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
||||
attachmentOption.value = AttachmentOption.TakePhoto
|
||||
hide()
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
||||
attachmentOption.value = AttachmentOption.PickImage
|
||||
hide()
|
||||
}
|
||||
if (allowVideoAttachment) {
|
||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Videocam) {
|
||||
attachmentOption.value = AttachmentOption.PickVideo
|
||||
hide()
|
||||
}
|
||||
}
|
||||
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
||||
attachmentOption.value = AttachmentOption.PickFile
|
||||
hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CloseSheetBar(close: () -> Unit, endButtons: @Composable RowScope.() -> Unit = {}) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = AppBarHeight)
|
||||
.padding(horizontal = AppBarHorizontalPadding),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 4.dp), // Like in DefaultAppBar
|
||||
content = {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(TextFieldDefaults.MinHeight),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
NavigationButtonBack(close)
|
||||
Row {
|
||||
endButtons()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBarTitle(title: String, withPadding: Boolean = true) {
|
||||
val padding = if (withPadding)
|
||||
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
|
||||
else
|
||||
PaddingValues(bottom = DEFAULT_PADDING)
|
||||
Text(
|
||||
title,
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(padding),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h1
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewCloseSheetBar() {
|
||||
SimpleXTheme {
|
||||
CloseSheetBar(close = {})
|
||||
}
|
||||
}
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun DefaultBasicTextField(
|
||||
modifier: Modifier,
|
||||
initialValue: String,
|
||||
placeholder: (@Composable () -> Unit)? = null,
|
||||
leadingIcon: (@Composable () -> Unit)? = null,
|
||||
focus: Boolean = false,
|
||||
color: Color = MaterialTheme.colors.onBackground,
|
||||
textStyle: TextStyle = TextStyle.Default,
|
||||
selectTextOnFocus: Boolean = false,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||
onValueChange: (String) -> Unit,
|
||||
) {
|
||||
val state = remember {
|
||||
mutableStateOf(TextFieldValue(initialValue))
|
||||
}
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!focus) return@LaunchedEffect
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
keyboard?.show()
|
||||
}
|
||||
val enabled = true
|
||||
val colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Unspecified,
|
||||
textColor = MaterialTheme.colors.onBackground,
|
||||
focusedIndicatorColor = Color.Unspecified,
|
||||
unfocusedIndicatorColor = Color.Unspecified,
|
||||
)
|
||||
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
BasicTextField(
|
||||
value = state.value,
|
||||
modifier = modifier
|
||||
.background(colors.backgroundColor(enabled).value, shape)
|
||||
.indicatorLine(enabled, false, interactionSource, colors)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
if (focusState.isFocused && selectTextOnFocus) {
|
||||
val text = state.value.text
|
||||
state.value = state.value.copy(
|
||||
selection = TextRange(0, text.length)
|
||||
)
|
||||
}
|
||||
}
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = TextFieldDefaults.MinHeight
|
||||
),
|
||||
onValueChange = {
|
||||
state.value = it
|
||||
onValueChange(it.text)
|
||||
},
|
||||
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboard?.hide()
|
||||
keyboardActions.onDone?.invoke(this)
|
||||
}),
|
||||
singleLine = true,
|
||||
textStyle = textStyle.copy(
|
||||
color = color,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = state.value.text,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = placeholder,
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
leadingIcon = leadingIcon,
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = colors
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.*
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
|
||||
sealed class LAResult {
|
||||
object Success: LAResult()
|
||||
class Error(val errString: CharSequence): LAResult()
|
||||
object Failed: LAResult()
|
||||
object Unavailable: LAResult()
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
activity: FragmentActivity,
|
||||
completed: (LAResult) -> Unit
|
||||
) {
|
||||
when {
|
||||
SDK_INT in 28..29 ->
|
||||
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
SDK_INT > 29 ->
|
||||
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
||||
else ->
|
||||
completed(LAResult.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticateWithBiometricManager(
|
||||
promptTitle: String,
|
||||
promptSubtitle: String,
|
||||
activity: FragmentActivity,
|
||||
completed: (LAResult) -> Unit,
|
||||
authenticators: Int
|
||||
) {
|
||||
val biometricManager = BiometricManager.from(activity)
|
||||
when (biometricManager.canAuthenticate(authenticators)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> {
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
activity,
|
||||
executor,
|
||||
object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(
|
||||
errorCode: Int,
|
||||
errString: CharSequence
|
||||
) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
completed(LAResult.Error(errString))
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult
|
||||
) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
completed(LAResult.Success)
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
completed(LAResult.Failed)
|
||||
}
|
||||
}
|
||||
)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(promptTitle)
|
||||
.setSubtitle(promptSubtitle)
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
.setConfirmationRequired(false)
|
||||
.build()
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
else -> {
|
||||
completed(LAResult.Unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_simplex_lock_turned_on),
|
||||
generalGetString(R.string.auth_you_will_be_required_to_authenticate_when_you_start_or_resume)
|
||||
)
|
||||
|
||||
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_unavailable),
|
||||
generalGetString(R.string.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled)
|
||||
)
|
||||
|
||||
fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.auth_unavailable),
|
||||
generalGetString(R.string.auth_device_authentication_is_disabled_turning_off)
|
||||
)
|
||||
@@ -1,65 +0,0 @@
|
||||
package chat.simplex.app.views.helpers
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
|
||||
@Composable
|
||||
fun TextEditor(
|
||||
modifier: Modifier,
|
||||
text: MutableState<String>,
|
||||
border: Boolean = true,
|
||||
fontSize: TextUnit = 14.sp,
|
||||
background: Color = MaterialTheme.colors.background,
|
||||
onChange: ((String) -> Unit)? = null
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text.value,
|
||||
onValueChange = { text.value = it; onChange?.invoke(it) },
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace, fontSize = fontSize,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
modifier = modifier,
|
||||
cursorBrush = SolidColor(HighOrLowlight),
|
||||
decorationBox = { innerTextField ->
|
||||
Surface(
|
||||
shape = if (border) RoundedCornerShape(10.dp) else RectangleShape,
|
||||
border = if (border) BorderStroke(1.dp, MaterialTheme.colors.secondary) else null
|
||||
) {
|
||||
Row(
|
||||
Modifier.background(background),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 5.dp, horizontal = if (border) 7.dp else DEFAULT_PADDING)
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package chat.simplex.app.views.newchat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.ChatModel
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun AddContactView(connReqInvitation: String, connIncognito: Boolean) {
|
||||
val cxt = LocalContext.current
|
||||
AddContactLayout(
|
||||
connReq = connReqInvitation,
|
||||
connIncognito = connIncognito,
|
||||
share = { shareText(cxt, connReqInvitation) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddContactLayout(connReq: String, connIncognito: Boolean, share: () -> Unit) {
|
||||
BoxWithConstraints {
|
||||
val screenHeight = maxHeight
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.add_contact), false)
|
||||
Text(
|
||||
stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline),
|
||||
)
|
||||
Row {
|
||||
InfoAboutIncognito(
|
||||
connIncognito,
|
||||
true,
|
||||
generalGetString(R.string.incognito_random_profile_description),
|
||||
generalGetString(R.string.your_profile_will_be_sent)
|
||||
)
|
||||
}
|
||||
if (connReq.isNotEmpty()) {
|
||||
QRCode(
|
||||
connReq, Modifier
|
||||
.aspectRatio(1f)
|
||||
.padding(vertical = 3.dp)
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
Text(
|
||||
annotatedStringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel),
|
||||
lineHeight = 22.sp,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp, bottom = if (screenHeight > 600.dp) 16.dp else 0.dp)
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String) {
|
||||
if (chatModelIncognito) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
if (supportedIncognito) Icons.Filled.TheaterComedy else Icons.Outlined.Info,
|
||||
stringResource(R.string.incognito),
|
||||
tint = if (supportedIncognito) Indigo else WarningOrange,
|
||||
modifier = Modifier.padding(end = 10.dp).size(20.dp)
|
||||
)
|
||||
Text(onText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Info,
|
||||
stringResource(R.string.incognito),
|
||||
tint = HighOrLowlight,
|
||||
modifier = Modifier.padding(end = 10.dp).size(20.dp)
|
||||
)
|
||||
Text(offText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewAddContactView() {
|
||||
SimpleXTheme {
|
||||
AddContactLayout(
|
||||
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
|
||||
connIncognito = false,
|
||||
share = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
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.User
|
||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun HowItWorks(user: User?, onboardingStage: MutableState<OnboardingStage?>? = null) {
|
||||
Column(Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.how_simplex_works), false)
|
||||
ReadableText(R.string.many_people_asked_how_can_it_deliver)
|
||||
ReadableText(R.string.to_protect_privacy_simplex_has_ids_for_queues)
|
||||
ReadableText(R.string.you_control_servers_to_receive_your_contacts_to_send)
|
||||
ReadableText(R.string.only_client_devices_store_contacts_groups_e2e_encrypted_messages)
|
||||
if (onboardingStage == null) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Text(
|
||||
annotatedStringResource(R.string.read_more_in_github_with_link),
|
||||
modifier = Modifier.padding(bottom = 12.dp).clickable { uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat#readme") },
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
} else {
|
||||
ReadableText(R.string.read_more_in_github)
|
||||
}
|
||||
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
|
||||
if (onboardingStage != null) {
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
|
||||
OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.shared.closeModal() })
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReadableText(@StringRes stringResId: Int) {
|
||||
Text(annotatedStringResource(stringResId), modifier = Modifier.padding(bottom = 12.dp), lineHeight = 22.sp)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewHowItWorks() {
|
||||
SimpleXTheme {
|
||||
HowItWorks(user = null)
|
||||
}
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||
import chat.simplex.app.views.usersettings.changeNotificationsMode
|
||||
|
||||
@Composable
|
||||
fun SetNotificationsMode(m: ChatModel) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.onboarding_notifications_mode_title), false)
|
||||
val currentMode = rememberSaveable { mutableStateOf(NotificationsMode.default) }
|
||||
Text(stringResource(R.string.onboarding_notifications_mode_subtitle))
|
||||
Spacer(Modifier.padding(DEFAULT_PADDING_HALF))
|
||||
NotificationButton(currentMode, NotificationsMode.OFF, R.string.onboarding_notifications_mode_off, R.string.onboarding_notifications_mode_off_desc)
|
||||
NotificationButton(currentMode, NotificationsMode.PERIODIC, R.string.onboarding_notifications_mode_periodic, R.string.onboarding_notifications_mode_periodic_desc)
|
||||
NotificationButton(currentMode, NotificationsMode.SERVICE, R.string.onboarding_notifications_mode_service, R.string.onboarding_notifications_mode_service_desc)
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 16.dp), contentAlignment = Alignment.Center) {
|
||||
OnboardingActionButton(R.string.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage) {
|
||||
changeNotificationsMode(currentMode.value, m)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationButton(currentMode: MutableState<NotificationsMode>, mode: NotificationsMode, @StringRes title: Int, @StringRes description: Int) {
|
||||
TextButton(
|
||||
onClick = { currentMode.value = mode },
|
||||
border = BorderStroke(1.dp, color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight.copy(alpha = 0.5f)),
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
) {
|
||||
Column(Modifier.padding(bottom = 6.dp).padding(horizontal = 8.dp)) {
|
||||
Text(
|
||||
stringResource(title),
|
||||
style = MaterialTheme.typography.h2,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (currentMode.value == mode) MaterialTheme.colors.primary else HighOrLowlight,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Text(annotatedStringResource(description), color = MaterialTheme.colors.onBackground, lineHeight = 24.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
package chat.simplex.app.views.onboarding
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) {
|
||||
val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) }
|
||||
|
||||
@Composable
|
||||
fun featureDescription(icon: ImageVector, titleId: Int, descrId: Int, link: String?) {
|
||||
@Composable
|
||||
fun linkButton(link: String) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Icon(
|
||||
Icons.Outlined.OpenInNew, stringResource(titleId), tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.clickable { uriHandler.openUriCatching(link) }
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(icon, stringResource(titleId), tint = HighOrLowlight)
|
||||
Text(
|
||||
generalGetString(titleId),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h3,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (link != null) {
|
||||
linkButton(link)
|
||||
}
|
||||
}
|
||||
Text(generalGetString(descrId))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun pagination() {
|
||||
Row(
|
||||
Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
if (currentVersion.value > 0) {
|
||||
val prev = currentVersion.value - 1
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.clickable { currentVersion.value = prev }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(Icons.Outlined.ArrowBackIosNew, "previous", tint = MaterialTheme.colors.primary)
|
||||
Text(versionDescriptions[prev].version, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
if (currentVersion.value < versionDescriptions.lastIndex) {
|
||||
val next = currentVersion.value + 1
|
||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.clickable { currentVersion.value = next }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(versionDescriptions[next].version, color = MaterialTheme.colors.primary)
|
||||
Icon(Icons.Outlined.ArrowForwardIos, "next", tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val v = versionDescriptions[currentVersion.value]
|
||||
|
||||
ModalView(close = close) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
String.format(generalGetString(R.string.new_in_version), v.version),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(DEFAULT_PADDING),
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.h1,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = HighOrLowlight
|
||||
)
|
||||
|
||||
v.features.forEach { feature ->
|
||||
featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link)
|
||||
}
|
||||
|
||||
if (!viaSettings) {
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Box(
|
||||
Modifier.fillMaxWidth(), contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
generalGetString(R.string.ok),
|
||||
modifier = Modifier.clickable(onClick = close),
|
||||
style = MaterialTheme.typography.h3,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
}
|
||||
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
|
||||
pagination()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class FeatureDescription(
|
||||
val icon: ImageVector,
|
||||
val titleId: Int,
|
||||
val descrId: Int,
|
||||
val link: String? = null
|
||||
)
|
||||
|
||||
private data class VersionDescription(
|
||||
val version: String,
|
||||
val features: List<FeatureDescription>
|
||||
)
|
||||
|
||||
private val versionDescriptions: List<VersionDescription> = listOf(
|
||||
VersionDescription(
|
||||
version = "v4.2",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.VerifiedUser,
|
||||
titleId = R.string.v4_2_security_assessment,
|
||||
descrId = R.string.v4_2_security_assessment_desc,
|
||||
link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Group,
|
||||
titleId = R.string.v4_2_group_links,
|
||||
descrId = R.string.v4_2_group_links_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Check,
|
||||
titleId = R.string.v4_2_auto_accept_contact_requests,
|
||||
descrId = R.string.v4_2_auto_accept_contact_requests_desc
|
||||
),
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v4.3",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Mic,
|
||||
titleId = R.string.v4_3_voice_messages,
|
||||
descrId = R.string.v4_3_voice_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.DeleteForever,
|
||||
titleId = R.string.v4_3_irreversible_message_deletion,
|
||||
descrId = R.string.v4_3_irreversible_message_deletion_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.WifiTethering,
|
||||
titleId = R.string.v4_3_improved_server_configuration,
|
||||
descrId = R.string.v4_3_improved_server_configuration_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.VisibilityOff,
|
||||
titleId = R.string.v4_3_improved_privacy_and_security,
|
||||
descrId = R.string.v4_3_improved_privacy_and_security_desc
|
||||
),
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v4.4",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Timer,
|
||||
titleId = R.string.v4_4_disappearing_messages,
|
||||
descrId = R.string.v4_4_disappearing_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Pending,
|
||||
titleId = R.string.v4_4_live_messages,
|
||||
descrId = R.string.v4_4_live_messages_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.VerifiedUser,
|
||||
titleId = R.string.v4_4_verify_connection_security,
|
||||
descrId = R.string.v4_4_verify_connection_security_desc
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Translate,
|
||||
titleId = R.string.v4_4_french_interface,
|
||||
descrId = R.string.v4_4_french_interface_descr
|
||||
)
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v4.5",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.ManageAccounts,
|
||||
titleId = R.string.v4_5_multiple_chat_profiles,
|
||||
descrId = R.string.v4_5_multiple_chat_profiles_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.EditNote,
|
||||
titleId = R.string.v4_5_message_draft,
|
||||
descrId = R.string.v4_5_message_draft_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.SafetyDivider,
|
||||
titleId = R.string.v4_5_transport_isolation,
|
||||
descrId = R.string.v4_5_transport_isolation_descr,
|
||||
link = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation"
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Task,
|
||||
titleId = R.string.v4_5_private_filenames,
|
||||
descrId = R.string.v4_5_private_filenames_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Battery2Bar,
|
||||
titleId = R.string.v4_5_reduced_battery_usage,
|
||||
descrId = R.string.v4_5_reduced_battery_usage_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Translate,
|
||||
titleId = R.string.v4_5_italian_interface,
|
||||
descrId = R.string.v4_5_italian_interface_descr,
|
||||
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#translate-the-apps"
|
||||
)
|
||||
)
|
||||
),
|
||||
VersionDescription(
|
||||
version = "v4.6",
|
||||
features = listOf(
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Lock,
|
||||
titleId = R.string.v4_6_hidden_chat_profiles,
|
||||
descrId = R.string.v4_6_hidden_chat_profiles_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Flag,
|
||||
titleId = R.string.v4_6_group_moderation,
|
||||
descrId = R.string.v4_6_group_moderation_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.MapsUgc,
|
||||
titleId = R.string.v4_6_group_welcome_message,
|
||||
descrId = R.string.v4_6_group_welcome_message_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Call,
|
||||
titleId = R.string.v4_6_audio_video_calls,
|
||||
descrId = R.string.v4_6_audio_video_calls_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Battery3Bar,
|
||||
titleId = R.string.v4_6_reduced_battery_usage,
|
||||
descrId = R.string.v4_6_reduced_battery_usage_descr
|
||||
),
|
||||
FeatureDescription(
|
||||
icon = Icons.Outlined.Translate,
|
||||
titleId = R.string.v4_6_chinese_spanish_interface,
|
||||
descrId = R.string.v4_6_chinese_spanish_interface_descr,
|
||||
link = "https://github.com/simplex-chat/simplex-chat/tree/stable#translate-the-apps"
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
private val lastVersion = versionDescriptions.last().version
|
||||
|
||||
fun setLastVersionDefault(m: ChatModel) {
|
||||
m.controller.appPrefs.whatsNewVersion.set(lastVersion)
|
||||
}
|
||||
|
||||
fun shouldShowWhatsNew(m: ChatModel): Boolean {
|
||||
val v = m.controller.appPrefs.whatsNewVersion.get()
|
||||
setLastVersionDefault(m)
|
||||
return v != lastVersion
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewWhatsNewView() {
|
||||
SimpleXTheme {
|
||||
WhatsNewView(
|
||||
viaSettings = true,
|
||||
close = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionCustomFooter
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.TheaterComedy
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun AcceptRequestsView(m: ChatModel, contactLink: UserContactLinkRec) {
|
||||
var contactLink by remember { mutableStateOf(contactLink) }
|
||||
AcceptRequestsLayout(
|
||||
contactLink,
|
||||
saveState = { new: MutableState<AutoAcceptState>, old: MutableState<AutoAcceptState> ->
|
||||
withApi {
|
||||
val link = m.controller.userAddressAutoAccept(new.value.autoAccept)
|
||||
if (link != null) {
|
||||
contactLink = link
|
||||
m.userAddress.value = link
|
||||
old.value = new.value
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AcceptRequestsLayout(
|
||||
contactLink: UserContactLinkRec,
|
||||
saveState: (new: MutableState<AutoAcceptState>, old: MutableState<AutoAcceptState>) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.contact_requests))
|
||||
val autoAcceptState = remember { mutableStateOf(AutoAcceptState(contactLink)) }
|
||||
val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) }
|
||||
SectionView(stringResource(R.string.accept_requests).uppercase()) {
|
||||
SectionItemView {
|
||||
PreferenceToggleWithIcon(stringResource(R.string.accept_automatically), Icons.Outlined.Check, checked = autoAcceptState.value.enable) {
|
||||
autoAcceptState.value = if (!it)
|
||||
AutoAcceptState()
|
||||
else
|
||||
AutoAcceptState(it, autoAcceptState.value.incognito, autoAcceptState.value.welcomeText)
|
||||
}
|
||||
}
|
||||
if (autoAcceptState.value.enable) {
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
PreferenceToggleWithIcon(
|
||||
stringResource(R.string.incognito),
|
||||
if (autoAcceptState.value.incognito) Icons.Filled.TheaterComedy else Icons.Outlined.TheaterComedy,
|
||||
if (autoAcceptState.value.incognito) Indigo else HighOrLowlight,
|
||||
autoAcceptState.value.incognito,
|
||||
) {
|
||||
autoAcceptState.value = AutoAcceptState(autoAcceptState.value.enable, it, autoAcceptState.value.welcomeText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val welcomeText = remember { mutableStateOf(autoAcceptState.value.welcomeText) }
|
||||
SectionCustomFooter(PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
ButtonsFooter(
|
||||
cancel = {
|
||||
autoAcceptState.value = autoAcceptStateSaved.value
|
||||
welcomeText.value = autoAcceptStateSaved.value.welcomeText
|
||||
},
|
||||
save = { saveState(autoAcceptState, autoAcceptStateSaved) },
|
||||
disabled = autoAcceptState.value == autoAcceptStateSaved.value
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
if (autoAcceptState.value.enable) {
|
||||
Text(
|
||||
stringResource(R.string.section_title_welcome_message), color = HighOrLowlight, style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), fontSize = 12.sp
|
||||
)
|
||||
TextEditor(Modifier.padding(horizontal = DEFAULT_PADDING).height(160.dp), text = welcomeText)
|
||||
LaunchedEffect(welcomeText.value) {
|
||||
if (welcomeText.value != autoAcceptState.value.welcomeText) {
|
||||
autoAcceptState.value = AutoAcceptState(autoAcceptState.value.enable, autoAcceptState.value.incognito, welcomeText.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonsFooter(cancel: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FooterButton(Icons.Outlined.Replay, stringResource(R.string.cancel_verb), cancel, disabled)
|
||||
FooterButton(Icons.Outlined.Check, stringResource(R.string.save_verb), save, disabled)
|
||||
}
|
||||
}
|
||||
|
||||
private class AutoAcceptState {
|
||||
var enable: Boolean = false
|
||||
private set
|
||||
var incognito: Boolean = false
|
||||
private set
|
||||
var welcomeText: String = ""
|
||||
private set
|
||||
|
||||
constructor(enable: Boolean = false, incognito: Boolean = false, welcomeText: String = "") {
|
||||
this.enable = enable
|
||||
this.incognito = incognito
|
||||
this.welcomeText = welcomeText
|
||||
}
|
||||
|
||||
constructor(contactLink: UserContactLinkRec) {
|
||||
contactLink.autoAccept?.let { aa ->
|
||||
enable = true
|
||||
incognito = aa.acceptIncognito
|
||||
aa.autoReply?.let { msg ->
|
||||
welcomeText = msg.text
|
||||
} ?: run {
|
||||
welcomeText = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val autoAccept: AutoAccept?
|
||||
get() {
|
||||
if (enable) {
|
||||
var autoReply: MsgContent? = null
|
||||
val s = welcomeText.trim()
|
||||
if (s != "") {
|
||||
autoReply = MsgContent.MCText(s)
|
||||
}
|
||||
return AutoAccept(incognito, autoReply)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is AutoAcceptState) return false
|
||||
return this.enable == other.enable && this.incognito == other.incognito && this.welcomeText == other.welcomeText
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = enable.hashCode()
|
||||
result = 31 * result + incognito.hashCode()
|
||||
result = 31 * result + welcomeText.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionCustomFooter
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemViewSpaceBetween
|
||||
import SectionItemWithValue
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.MaterialTheme.colors
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import chat.simplex.app.*
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.model.SharedPreference
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import com.godaddy.android.colorpicker.*
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.*
|
||||
|
||||
enum class AppIcon(val resId: Int) {
|
||||
DEFAULT(R.mipmap.icon),
|
||||
DARK_BLUE(R.mipmap.icon_dark_blue),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppearanceView(m: ChatModel) {
|
||||
val appIcon = remember { mutableStateOf(findEnabledIcon()) }
|
||||
|
||||
fun setAppIcon(newIcon: AppIcon) {
|
||||
if (appIcon.value == newIcon) return
|
||||
val newComponent = ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${newIcon.name.lowercase()}")
|
||||
val oldComponent = ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${appIcon.value.name.lowercase()}")
|
||||
SimplexApp.context.packageManager.setComponentEnabledSetting(
|
||||
newComponent,
|
||||
COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP
|
||||
)
|
||||
|
||||
SimplexApp.context.packageManager.setComponentEnabledSetting(
|
||||
oldComponent,
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP
|
||||
)
|
||||
|
||||
appIcon.value = newIcon
|
||||
}
|
||||
|
||||
AppearanceLayout(
|
||||
appIcon,
|
||||
m.controller.appPrefs.appLanguage,
|
||||
changeIcon = ::setAppIcon,
|
||||
editPrimaryColor = { primary ->
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
ColorEditor(primary, close)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun AppearanceLayout(
|
||||
icon: MutableState<AppIcon>,
|
||||
languagePref: SharedPreference<String?>,
|
||||
changeIcon: (AppIcon) -> Unit,
|
||||
editPrimaryColor: (Color) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.appearance_settings))
|
||||
SectionView(stringResource(R.string.settings_section_title_language), padding = PaddingValues()) {
|
||||
val context = LocalContext.current
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// SectionItemWithValue(
|
||||
// generalGetString(R.string.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
|
||||
// remember { mutableStateOf("system") },
|
||||
// listOf(ValueTitleDesc("system", generalGetString(R.string.change_verb), "")),
|
||||
// onSelected = { openSystemLangPicker(context as? Activity ?: return@SectionItemWithValue) }
|
||||
// )
|
||||
// } else {
|
||||
val state = rememberSaveable { mutableStateOf(languagePref.get() ?: "system") }
|
||||
SectionItemView {
|
||||
LangSelector(state) {
|
||||
state.value = it
|
||||
withApi {
|
||||
delay(200)
|
||||
val activity = context as? Activity
|
||||
if (activity != null) {
|
||||
if (it == "system") {
|
||||
saveAppLocale(languagePref, activity)
|
||||
} else {
|
||||
saveAppLocale(languagePref, activity, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_icon), padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
|
||||
LazyRow {
|
||||
items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index ->
|
||||
val item = AppIcon.values()[index]
|
||||
val mipmap = ContextCompat.getDrawable(LocalContext.current, item.resId)!!
|
||||
Image(
|
||||
bitmap = mipmap.toBitmap().asImageBitmap(),
|
||||
contentDescription = "",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.shadow(if (item == icon.value) 1.dp else 0.dp, ambientColor = colors.secondary)
|
||||
.size(70.dp)
|
||||
.clickable { changeIcon(item) }
|
||||
.padding(10.dp)
|
||||
)
|
||||
|
||||
if (index + 1 != AppIcon.values().size) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
val currentTheme by CurrentColors.collectAsState()
|
||||
SectionView(stringResource(R.string.settings_section_title_themes)) {
|
||||
SectionItemViewSpaceBetween {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val state = remember { derivedStateOf { currentTheme.second } }
|
||||
ThemeSelector(state) {
|
||||
ThemeManager.applyTheme(it.name, darkTheme)
|
||||
}
|
||||
}
|
||||
SectionDivider()
|
||||
SectionItemViewSpaceBetween({ editPrimaryColor(currentTheme.first.primary) }) {
|
||||
val title = generalGetString(R.string.color_primary)
|
||||
Text(title)
|
||||
Icon(Icons.Filled.Circle, title, tint = colors.primary)
|
||||
}
|
||||
}
|
||||
if (currentTheme.first.primary != LightColorPalette.primary) {
|
||||
SectionCustomFooter(PaddingValues(start = 7.dp, end = 7.dp, top = 5.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
ThemeManager.saveAndApplyPrimaryColor(LightColorPalette.primary)
|
||||
},
|
||||
) {
|
||||
Text(generalGetString(R.string.reset_color))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorEditor(
|
||||
initialColor: Color,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.color_primary))
|
||||
var currentColor by remember { mutableStateOf(initialColor) }
|
||||
ColorPicker(initialColor) {
|
||||
currentColor = it
|
||||
}
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
ThemeManager.saveAndApplyPrimaryColor(currentColor)
|
||||
close()
|
||||
},
|
||||
Modifier.align(Alignment.CenterHorizontally),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = currentColor)
|
||||
) {
|
||||
Text(generalGetString(R.string.save_color))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorPicker(initialColor: Color, onColorChanged: (Color) -> Unit) {
|
||||
ClassicColorPicker(
|
||||
color = initialColor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
showAlphaBar = false,
|
||||
onColorChanged = { color: HsvColor ->
|
||||
onColorChanged(color.toColor())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LangSelector(state: State<String>, onSelected: (String) -> Unit) {
|
||||
// Should be the same as in app/build.gradle's `android.defaultConfig.resConfigs`
|
||||
val supportedLanguages = mapOf(
|
||||
"system" to generalGetString(R.string.language_system),
|
||||
"en" to "English",
|
||||
"cs" to "Čeština",
|
||||
"de" to "Deutsch",
|
||||
"es" to "Español",
|
||||
"fr" to "Français",
|
||||
"it" to "Italiano",
|
||||
"nl" to "Nederlands",
|
||||
"ru" to "Русский",
|
||||
"zh-CN" to "简体中文"
|
||||
)
|
||||
val values by remember { mutableStateOf(supportedLanguages.map { it.key to it.value }) }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() },
|
||||
values,
|
||||
state,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeSelector(state: State<DefaultTheme>, onSelected: (DefaultTheme) -> Unit) {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val values by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second to it.third }) }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.theme),
|
||||
values,
|
||||
state,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
//private fun openSystemLangPicker(activity: Activity) {
|
||||
// activity.startActivity(Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.parse("package:" + SimplexApp.context.packageName)))
|
||||
//}
|
||||
|
||||
private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon ->
|
||||
SimplexApp.context.packageManager.getComponentEnabledSetting(
|
||||
ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}")
|
||||
).let { it == COMPONENT_ENABLED_STATE_DEFAULT || it == COMPONENT_ENABLED_STATE_ENABLED }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewAppearanceSettings() {
|
||||
SimpleXTheme {
|
||||
AppearanceLayout(
|
||||
icon = remember { mutableStateOf(AppIcon.DARK_BLUE) },
|
||||
languagePref = SharedPreference({ null }, {}),
|
||||
changeIcon = {},
|
||||
editPrimaryColor = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.TerminalView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun DeveloperView(
|
||||
m: ChatModel,
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
withAuth: (block: () -> Unit) -> Unit
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
AppBarTitle(stringResource(R.string.settings_developer_tools))
|
||||
val developerTools = m.controller.appPrefs.developerTools
|
||||
val devTools = remember { mutableStateOf(developerTools.get()) }
|
||||
SectionView() {
|
||||
InstallTerminalAppItem(uriHandler)
|
||||
SectionDivider()
|
||||
ChatConsoleItem { withAuth(showCustomModal { it, close -> TerminalView(it, close) }) }
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.DriveFolderUpload, stringResource(R.string.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.Code, stringResource(R.string.show_developer_options), developerTools, devTools)
|
||||
}
|
||||
SectionTextFooter(
|
||||
generalGetString(if (devTools.value) R.string.show_dev_options else R.string.hide_dev_options) + " " +
|
||||
generalGetString(R.string.developer_options)
|
||||
)
|
||||
SectionSpacer()
|
||||
|
||||
val xftpSendEnabled = m.controller.appPrefs.xftpSendEnabled
|
||||
val xftpEnabled = remember { mutableStateOf(xftpSendEnabled.get()) }
|
||||
SectionView(generalGetString(R.string.settings_section_title_experimenta)) {
|
||||
SettingsPreferenceItem(Icons.Outlined.UploadFile, stringResource(R.string.settings_send_files_via_xftp), xftpSendEnabled, xftpEnabled) {
|
||||
withApi { m.controller.apiSetXFTPConfig(m.controller.getXFTPCfg()) }
|
||||
}
|
||||
}
|
||||
if (xftpEnabled.value) {
|
||||
SectionTextFooter(generalGetString(R.string.xftp_requires_v461))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.UploadFile
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.ChatModel
|
||||
import chat.simplex.app.views.helpers.withApi
|
||||
|
||||
@Composable
|
||||
fun ExperimentalFeaturesView(chatModel: ChatModel) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_experimental_features),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
|
||||
)
|
||||
SectionView("") {
|
||||
SettingsPreferenceItem(Icons.Outlined.UploadFile, stringResource(R.string.settings_send_files_via_xftp), chatModel.controller.appPrefs.xftpSendEnabled) {
|
||||
withApi {
|
||||
chatModel.controller.apiSetXFTPConfig(chatModel.controller.getXFTPCfg())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-311
@@ -1,311 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionItemWithValue
|
||||
import SectionView
|
||||
import SectionViewSelectable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun NetworkAndServersView(
|
||||
chatModel: ChatModel,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
) {
|
||||
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
|
||||
val netCfg = remember { chatModel.controller.getNetCfg() }
|
||||
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
|
||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
|
||||
val sessionMode = remember { mutableStateOf(netCfg.sessionMode) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
chatModel.userSMPServersUnsaved.value = null
|
||||
}
|
||||
|
||||
NetworkAndServersLayout(
|
||||
developerTools = developerTools,
|
||||
networkUseSocksProxy = networkUseSocksProxy,
|
||||
onionHosts = onionHosts,
|
||||
sessionMode = sessionMode,
|
||||
showModal = showModal,
|
||||
showSettingsModal = showSettingsModal,
|
||||
showCustomModal = showCustomModal,
|
||||
toggleSocksProxy = { enable ->
|
||||
if (enable) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.network_enable_socks),
|
||||
text = generalGetString(R.string.network_enable_socks_info),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.apiSetNetworkConfig(NetCfg.proxyDefaults)
|
||||
chatModel.controller.setNetCfg(NetCfg.proxyDefaults)
|
||||
networkUseSocksProxy.value = true
|
||||
onionHosts.value = NetCfg.proxyDefaults.onionHosts
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.network_disable_socks),
|
||||
text = generalGetString(R.string.network_disable_socks_info),
|
||||
confirmText = generalGetString(R.string.confirm_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.apiSetNetworkConfig(NetCfg.defaults)
|
||||
chatModel.controller.setNetCfg(NetCfg.defaults)
|
||||
networkUseSocksProxy.value = false
|
||||
onionHosts.value = NetCfg.defaults.onionHosts
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
useOnion = {
|
||||
if (onionHosts.value == it) return@NetworkAndServersLayout
|
||||
val prevValue = onionHosts.value
|
||||
onionHosts.value = it
|
||||
val startsWith = when (it) {
|
||||
OnionHosts.NEVER -> generalGetString(R.string.network_use_onion_hosts_no_desc_in_alert)
|
||||
OnionHosts.PREFER -> generalGetString(R.string.network_use_onion_hosts_prefer_desc_in_alert)
|
||||
OnionHosts.REQUIRED -> generalGetString(R.string.network_use_onion_hosts_required_desc_in_alert)
|
||||
}
|
||||
updateNetworkSettingsDialog(
|
||||
title = generalGetString(R.string.update_onion_hosts_settings_question),
|
||||
startsWith,
|
||||
onDismiss = {
|
||||
onionHosts.value = prevValue
|
||||
}
|
||||
) {
|
||||
withApi {
|
||||
val newCfg = chatModel.controller.getNetCfg().withOnionHosts(it)
|
||||
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
|
||||
if (res) {
|
||||
chatModel.controller.setNetCfg(newCfg)
|
||||
onionHosts.value = it
|
||||
} else {
|
||||
onionHosts.value = prevValue
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updateSessionMode = {
|
||||
if (sessionMode.value == it) return@NetworkAndServersLayout
|
||||
val prevValue = sessionMode.value
|
||||
sessionMode.value = it
|
||||
val startsWith = when (it) {
|
||||
TransportSessionMode.User -> generalGetString(R.string.network_session_mode_user_description)
|
||||
TransportSessionMode.Entity -> generalGetString(R.string.network_session_mode_entity_description)
|
||||
}
|
||||
updateNetworkSettingsDialog(
|
||||
title = generalGetString(R.string.update_network_session_mode_question),
|
||||
startsWith,
|
||||
onDismiss = { sessionMode.value = prevValue }
|
||||
) {
|
||||
withApi {
|
||||
val newCfg = chatModel.controller.getNetCfg().copy(sessionMode = it)
|
||||
val res = chatModel.controller.apiSetNetworkConfig(newCfg)
|
||||
if (res) {
|
||||
chatModel.controller.setNetCfg(newCfg)
|
||||
sessionMode.value = it
|
||||
} else {
|
||||
sessionMode.value = prevValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun NetworkAndServersLayout(
|
||||
developerTools: Boolean,
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
sessionMode: MutableState<TransportSessionMode>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
toggleSocksProxy: (Boolean) -> Unit,
|
||||
useOnion: (OnionHosts) -> Unit,
|
||||
updateSessionMode: (TransportSessionMode) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.network_and_servers))
|
||||
SectionView(generalGetString(R.string.settings_section_title_messages)) {
|
||||
SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showCustomModal { m, close -> SMPServersView(m, close) })
|
||||
SectionDivider()
|
||||
SectionItemView {
|
||||
UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy)
|
||||
}
|
||||
SectionDivider()
|
||||
UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion)
|
||||
SectionDivider()
|
||||
if (developerTools) {
|
||||
SessionModePicker(sessionMode, showSettingsModal, updateSessionMode)
|
||||
SectionDivider()
|
||||
}
|
||||
SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) })
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionView(generalGetString(R.string.settings_section_title_calls)) {
|
||||
SettingsActionItem(Icons.Outlined.ElectricalServices, stringResource(R.string.webrtc_ice_servers), showModal { RTCServersView(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UseSocksProxySwitch(
|
||||
networkUseSocksProxy: MutableState<Boolean>,
|
||||
toggleSocksProxy: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.SettingsEthernet,
|
||||
stringResource(R.string.network_socks_toggle),
|
||||
tint = HighOrLowlight
|
||||
)
|
||||
Text(stringResource(R.string.network_socks_toggle))
|
||||
}
|
||||
Switch(
|
||||
checked = networkUseSocksProxy.value,
|
||||
onCheckedChange = toggleSocksProxy,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UseOnionHosts(
|
||||
onionHosts: MutableState<OnionHosts>,
|
||||
enabled: State<Boolean>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
useOnion: (OnionHosts) -> Unit,
|
||||
) {
|
||||
val values = remember {
|
||||
OnionHosts.values().map {
|
||||
when (it) {
|
||||
OnionHosts.NEVER -> ValueTitleDesc(OnionHosts.NEVER, generalGetString(R.string.network_use_onion_hosts_no), generalGetString(R.string.network_use_onion_hosts_no_desc))
|
||||
OnionHosts.PREFER -> ValueTitleDesc(OnionHosts.PREFER, generalGetString(R.string.network_use_onion_hosts_prefer), generalGetString(R.string.network_use_onion_hosts_prefer_desc))
|
||||
OnionHosts.REQUIRED -> ValueTitleDesc(OnionHosts.REQUIRED, generalGetString(R.string.network_use_onion_hosts_required), generalGetString(R.string.network_use_onion_hosts_required_desc))
|
||||
}
|
||||
}
|
||||
}
|
||||
val onSelected = showModal {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.network_use_onion_hosts))
|
||||
SectionViewSelectable(null, onionHosts, values, useOnion)
|
||||
}
|
||||
}
|
||||
|
||||
SectionItemWithValue(
|
||||
generalGetString(R.string.network_use_onion_hosts),
|
||||
onionHosts,
|
||||
values,
|
||||
icon = Icons.Outlined.Security,
|
||||
enabled = enabled,
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionModePicker(
|
||||
sessionMode: MutableState<TransportSessionMode>,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
updateSessionMode: (TransportSessionMode) -> Unit,
|
||||
) {
|
||||
val values = remember {
|
||||
TransportSessionMode.values().map {
|
||||
when (it) {
|
||||
TransportSessionMode.User -> ValueTitleDesc(TransportSessionMode.User, generalGetString(R.string.network_session_mode_user), generalGetString(R.string.network_session_mode_user_description))
|
||||
TransportSessionMode.Entity -> ValueTitleDesc(TransportSessionMode.Entity, generalGetString(R.string.network_session_mode_entity), generalGetString(R.string.network_session_mode_entity_description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionItemWithValue(
|
||||
generalGetString(R.string.network_session_mode_transport_isolation),
|
||||
sessionMode,
|
||||
values,
|
||||
icon = Icons.Outlined.SafetyDivider,
|
||||
onSelected = showModal {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.network_session_mode_transport_isolation))
|
||||
SectionViewSelectable(null, sessionMode, values, updateSessionMode)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateNetworkSettingsDialog(
|
||||
title: String,
|
||||
startsWith: String = "",
|
||||
message: String = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers),
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = title,
|
||||
text = startsWith + "\n\n" + message,
|
||||
confirmText = generalGetString(R.string.update_network_settings_confirmation),
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = onConfirm,
|
||||
onDismissRequest = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewNetworkAndServersLayout() {
|
||||
SimpleXTheme {
|
||||
NetworkAndServersLayout(
|
||||
developerTools = true,
|
||||
networkUseSocksProxy = remember { mutableStateOf(true) },
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showCustomModal = { {} },
|
||||
toggleSocksProxy = {},
|
||||
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
|
||||
sessionMode = remember { mutableStateOf(TransportSessionMode.User) },
|
||||
useOnion = {},
|
||||
updateSessionMode = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
|
||||
@Composable
|
||||
fun PrivacySettingsView(
|
||||
chatModel: ChatModel,
|
||||
setPerformLA: (Boolean) -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode
|
||||
AppBarTitle(stringResource(R.string.your_privacy))
|
||||
SectionView(stringResource(R.string.settings_section_title_device)) {
|
||||
ChatLockItem(chatModel.performLA, setPerformLA)
|
||||
SectionDivider()
|
||||
val context = LocalContext.current
|
||||
SettingsPreferenceItem(Icons.Outlined.VisibilityOff, stringResource(R.string.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen) { on ->
|
||||
if (on) {
|
||||
(context as? FragmentActivity)?.window?.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE
|
||||
)
|
||||
} else {
|
||||
(context as? FragmentActivity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_chats)) {
|
||||
SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages)
|
||||
SectionDivider()
|
||||
SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
|
||||
SectionDivider()
|
||||
SectionItemView { SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = {
|
||||
simplexLinkMode.set(it)
|
||||
chatModel.simplexLinkMode.value = it
|
||||
}) }
|
||||
}
|
||||
if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) {
|
||||
SectionTextFooter(stringResource(R.string.simplex_link_mode_browser_warning))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimpleXLinkOptions(simplexLinkModeState: State<SimplexLinkMode>, onSelected: (SimplexLinkMode) -> Unit) {
|
||||
val values = remember {
|
||||
SimplexLinkMode.values().map {
|
||||
when (it) {
|
||||
SimplexLinkMode.DESCRIPTION -> it to generalGetString(R.string.simplex_link_mode_description)
|
||||
SimplexLinkMode.FULL -> it to generalGetString(R.string.simplex_link_mode_full)
|
||||
SimplexLinkMode.BROWSER -> it to generalGetString(R.string.simplex_link_mode_browser)
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(R.string.simplex_link_mode),
|
||||
values,
|
||||
simplexLinkModeState,
|
||||
icon = null,
|
||||
enabled = remember { mutableStateOf(true) },
|
||||
onSelected = onSelected
|
||||
)
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionView
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.*
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
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.TerminalView
|
||||
import chat.simplex.app.views.database.DatabaseView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.CreateLinkTab
|
||||
import chat.simplex.app.views.newchat.CreateLinkView
|
||||
import chat.simplex.app.views.onboarding.SimpleXInfo
|
||||
import chat.simplex.app.views.onboarding.WhatsNewView
|
||||
|
||||
@Composable
|
||||
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
|
||||
MaintainIncognitoState(chatModel)
|
||||
|
||||
if (user != null) {
|
||||
val requireAuth = remember { chatModel.controller.appPrefs.performLA.state }
|
||||
val context = LocalContext.current
|
||||
SettingsLayout(
|
||||
profile = user.profile,
|
||||
stopped,
|
||||
chatModel.chatDbEncrypted.value == true,
|
||||
chatModel.incognito,
|
||||
chatModel.controller.appPrefs.incognito,
|
||||
user.displayName,
|
||||
setPerformLA = setPerformLA,
|
||||
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
|
||||
showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { modalView(chatModel) } } },
|
||||
showSettingsModalWithSearch = { modalView ->
|
||||
ModalManager.shared.showCustomModal { close ->
|
||||
val search = rememberSaveable { mutableStateOf("") }
|
||||
ModalView(
|
||||
{ close() },
|
||||
if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight,
|
||||
endButtons = {
|
||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), alwaysVisible = true) { search.value = it }
|
||||
},
|
||||
content = { modalView(chatModel, search) })
|
||||
}
|
||||
},
|
||||
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
|
||||
showVersion = {
|
||||
withApi {
|
||||
val info = chatModel.controller.apiGetVersion()
|
||||
if (info != null) {
|
||||
ModalManager.shared.showModal { VersionInfoView(info) }
|
||||
}
|
||||
}
|
||||
},
|
||||
withAuth = { block ->
|
||||
if (!requireAuth.value) {
|
||||
block()
|
||||
} else {
|
||||
ModalManager.shared.showModalCloseable { close ->
|
||||
val onFinishAuth = { success: Boolean ->
|
||||
if (success) {
|
||||
close()
|
||||
block()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
runAuth(context, onFinishAuth)
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.auth_unlock),
|
||||
icon = Icons.Outlined.Lock,
|
||||
click = {
|
||||
runAuth(context, onFinishAuth)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val simplexTeamUri =
|
||||
"simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
|
||||
|
||||
@Composable
|
||||
fun SettingsLayout(
|
||||
profile: LocalProfile,
|
||||
stopped: Boolean,
|
||||
encrypted: Boolean,
|
||||
incognito: MutableState<Boolean>,
|
||||
incognitoPref: SharedPreference<Boolean>,
|
||||
userDisplayName: String,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
|
||||
showSettingsModalWithSearch: (@Composable (ChatModel, MutableState<String>) -> Unit) -> Unit,
|
||||
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
|
||||
showVersion: () -> Unit,
|
||||
withAuth: (block: () -> Unit) -> Unit
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Surface(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight)
|
||||
.padding(top = DEFAULT_PADDING)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.your_settings),
|
||||
style = MaterialTheme.typography.h1,
|
||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(30.dp))
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_you)) {
|
||||
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) {
|
||||
ProfilePreview(profile, stopped = stopped)
|
||||
}
|
||||
SectionDivider()
|
||||
val profileHidden = rememberSaveable { mutableStateOf(false) }
|
||||
SettingsActionItem(Icons.Outlined.ManageAccounts, stringResource(R.string.your_chat_profiles), { withAuth { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() }
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { CreateLinkView(it, CreateLinkTab.LONG_TERM) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
ChatPreferencesItem(showCustomModal, stopped = stopped)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_settings)) {
|
||||
SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal, showCustomModal) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView(it) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_help)) {
|
||||
SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Add, stringResource(R.string.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) })
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUriCatching(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped)
|
||||
SectionDivider()
|
||||
SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_support)) {
|
||||
ContributeItem(uriHandler)
|
||||
SectionDivider()
|
||||
RateAppItem(uriHandler)
|
||||
SectionDivider()
|
||||
StarOnGithubItem(uriHandler)
|
||||
}
|
||||
SectionSpacer()
|
||||
|
||||
SectionView(stringResource(R.string.settings_section_title_develop)) {
|
||||
SettingsActionItem(Icons.Outlined.Code, stringResource(R.string.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) })
|
||||
SectionDivider()
|
||||
// SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it) })
|
||||
// SectionDivider()
|
||||
AppVersionItem(showVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsIncognitoActionItem(
|
||||
incognitoPref: SharedPreference<Boolean>,
|
||||
incognito: MutableState<Boolean>,
|
||||
stopped: Boolean,
|
||||
onClickInfo: () -> Unit,
|
||||
) {
|
||||
SettingsPreferenceItemWithInfo(
|
||||
if (incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.TheaterComedy,
|
||||
if (incognito.value) Indigo else HighOrLowlight,
|
||||
stringResource(R.string.incognito),
|
||||
stopped,
|
||||
onClickInfo,
|
||||
incognitoPref,
|
||||
incognito
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MaintainIncognitoState(chatModel: ChatModel) {
|
||||
// Cache previous value and once it changes in background, update it via API
|
||||
var cachedIncognito by remember { mutableStateOf(chatModel.incognito.value) }
|
||||
LaunchedEffect(chatModel.incognito.value) {
|
||||
// Don't do anything if nothing changed
|
||||
if (cachedIncognito == chatModel.incognito.value) return@LaunchedEffect
|
||||
try {
|
||||
chatModel.controller.apiSetIncognito(chatModel.incognito.value)
|
||||
} catch (e: Exception) {
|
||||
// Rollback the state
|
||||
chatModel.controller.appPrefs.incognito.set(cachedIncognito)
|
||||
// Crash the app
|
||||
throw e
|
||||
}
|
||||
cachedIncognito = chatModel.incognito.value
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) {
|
||||
SectionItemView(openDatabaseView) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row {
|
||||
Icon(
|
||||
Icons.Outlined.FolderOpen,
|
||||
contentDescription = stringResource(R.string.database_passphrase_and_export),
|
||||
tint = if (encrypted) HighOrLowlight else WarningOrange,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.database_passphrase_and_export))
|
||||
}
|
||||
if (stopped) {
|
||||
Icon(
|
||||
Icons.Filled.Report,
|
||||
contentDescription = stringResource(R.string.chat_is_stopped),
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit)), stopped: Boolean) {
|
||||
SettingsActionItem(
|
||||
Icons.Outlined.ToggleOn,
|
||||
stringResource(R.string.chat_preferences),
|
||||
click = if (stopped) null else ({
|
||||
withApi {
|
||||
showCustomModal { m, close ->
|
||||
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
|
||||
}()
|
||||
}
|
||||
}),
|
||||
disabled = stopped
|
||||
)
|
||||
}
|
||||
|
||||
@Composable fun ChatLockItem(performLA: MutableState<Boolean>, setPerformLA: (Boolean) -> Unit) {
|
||||
SectionItemView() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Lock,
|
||||
contentDescription = stringResource(R.string.chat_lock),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
stringResource(R.string.chat_lock), Modifier
|
||||
.padding(end = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
)
|
||||
Switch(
|
||||
checked = performLA.value,
|
||||
onCheckedChange = { setPerformLA(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun ContributeItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat#contribute") }) {
|
||||
Icon(
|
||||
Icons.Outlined.Keyboard,
|
||||
contentDescription = "GitHub",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(generalGetString(R.string.contribute), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun RateAppItem(uriHandler: UriHandler) {
|
||||
SectionItemView({
|
||||
runCatching { uriHandler.openUriCatching("market://details?id=chat.simplex.app") }
|
||||
.onFailure { uriHandler.openUriCatching("https://play.google.com/store/apps/details?id=chat.simplex.app") }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.StarOutline,
|
||||
contentDescription = "Google Play",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(generalGetString(R.string.rate_the_app), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun StarOnGithubItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(generalGetString(R.string.star_on_github), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun ChatConsoleItem(showTerminal: () -> Unit) {
|
||||
SectionItemView(showTerminal) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_outline_terminal),
|
||||
contentDescription = stringResource(R.string.chat_console),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.chat_console))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun InstallTerminalAppItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_github),
|
||||
contentDescription = "GitHub",
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(generalGetString(R.string.install_simplex_chat_for_terminal), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun AppVersionItem(showVersion: () -> Unit) {
|
||||
SectionItemView(showVersion) { AppVersionText() }
|
||||
}
|
||||
|
||||
@Composable fun AppVersionText() {
|
||||
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
}
|
||||
|
||||
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, color: Color = MaterialTheme.colors.secondary, stopped: Boolean = false) {
|
||||
ProfileImage(size = size, image = profileOf.image, color = color)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Column {
|
||||
Text(
|
||||
profileOf.displayName,
|
||||
style = MaterialTheme.typography.caption,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (stopped) HighOrLowlight else Color.Unspecified,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
profileOf.fullName,
|
||||
color = if (stopped) HighOrLowlight else Color.Unspecified,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, iconColor: Color = HighOrLowlight, disabled: Boolean = false) {
|
||||
SectionItemView(click, disabled = disabled) {
|
||||
Icon(icon, text, tint = if (disabled) HighOrLowlight else iconColor)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(text, color = if (disabled) HighOrLowlight else textColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsPreferenceItem(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
pref: SharedPreference<Boolean>,
|
||||
prefState: MutableState<Boolean>? = null,
|
||||
onChange: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
SectionItemView {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, text, tint = HighOrLowlight)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
SharedPreferenceToggle(text, pref, prefState, onChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsPreferenceItemWithInfo(
|
||||
icon: ImageVector,
|
||||
iconTint: Color,
|
||||
text: String,
|
||||
stopped: Boolean,
|
||||
onClickInfo: () -> Unit,
|
||||
pref: SharedPreference<Boolean>,
|
||||
prefState: MutableState<Boolean>? = null
|
||||
) {
|
||||
SectionItemView(if (stopped) null else onClickInfo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, text, tint = if (stopped) HighOrLowlight else iconTint)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
SharedPreferenceToggleWithIcon(text, Icons.Outlined.Info, stopped, onClickInfo, pref, prefState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceToggle(
|
||||
text: String,
|
||||
checked: Boolean,
|
||||
onChange: (Boolean) -> Unit = {},
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceToggleWithIcon(
|
||||
text: String,
|
||||
icon: ImageVector? = null,
|
||||
iconColor: Color? = HighOrLowlight,
|
||||
checked: Boolean,
|
||||
onChange: (Boolean) -> Unit = {},
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
icon,
|
||||
null,
|
||||
tint = iconColor ?: HighOrLowlight
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
Text(text)
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
onChange(it)
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colors.primary,
|
||||
uncheckedThumbColor = HighOrLowlight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runAuth(context: Context, onFinish: (success: Boolean) -> Unit) {
|
||||
authenticate(
|
||||
generalGetString(R.string.auth_open_chat_console),
|
||||
generalGetString(R.string.auth_log_in_using_credential),
|
||||
context as FragmentActivity,
|
||||
completed = { laResult ->
|
||||
onFinish(laResult == LAResult.Success || laResult == LAResult.Unavailable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSettingsLayout() {
|
||||
SimpleXTheme {
|
||||
SettingsLayout(
|
||||
profile = LocalProfile.sampleData,
|
||||
stopped = false,
|
||||
encrypted = false,
|
||||
incognito = remember { mutableStateOf(false) },
|
||||
incognitoPref = SharedPreference({ false }, {}),
|
||||
userDisplayName = "Alice",
|
||||
setPerformLA = {},
|
||||
showModal = { {} },
|
||||
showSettingsModal = { {} },
|
||||
showSettingsModalWithSearch = { },
|
||||
showCustomModal = { {} },
|
||||
showVersion = {},
|
||||
withAuth = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.ChatModel
|
||||
import chat.simplex.app.model.UserContactLinkRec
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.newchat.QRCode
|
||||
|
||||
@Composable
|
||||
fun UserAddressView(chatModel: ChatModel) {
|
||||
val cxt = LocalContext.current
|
||||
UserAddressLayout(
|
||||
userAddress = remember { chatModel.userAddress }.value,
|
||||
createAddress = {
|
||||
withApi {
|
||||
val connReqContact = chatModel.controller.apiCreateUserAddress()
|
||||
if (connReqContact != null) {
|
||||
chatModel.userAddress.value = UserContactLinkRec(connReqContact)
|
||||
}
|
||||
}
|
||||
},
|
||||
share = { userAddress: String -> shareText(cxt, userAddress) },
|
||||
acceptRequests = {
|
||||
chatModel.userAddress.value?.let { address ->
|
||||
ModalManager.shared.showModal(settings = true) { AcceptRequestsView(chatModel, address) }
|
||||
}
|
||||
},
|
||||
deleteAddress = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(R.string.delete_address__question),
|
||||
text = generalGetString(R.string.all_your_contacts_will_remain_connected),
|
||||
confirmText = generalGetString(R.string.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
chatModel.controller.apiDeleteUserAddress()
|
||||
chatModel.userAddress.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserAddressLayout(
|
||||
userAddress: UserContactLinkRec?,
|
||||
createAddress: () -> Unit,
|
||||
share: (String) -> Unit,
|
||||
acceptRequests: () -> Unit,
|
||||
deleteAddress: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.your_contact_address), false)
|
||||
Text(
|
||||
stringResource(R.string.you_can_share_your_address_anybody_will_be_able_to_connect),
|
||||
Modifier.padding(bottom = 12.dp),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
if (userAddress == null) {
|
||||
SimpleButton(stringResource(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress)
|
||||
} else {
|
||||
QRCode(userAddress.connReqContact, Modifier.aspectRatio(1f))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
SimpleButton(
|
||||
stringResource(R.string.share_link),
|
||||
icon = Icons.Outlined.Share,
|
||||
click = { share(userAddress.connReqContact) })
|
||||
SimpleButtonIconEnded(
|
||||
stringResource(R.string.contact_requests),
|
||||
icon = Icons.Outlined.ChevronRight,
|
||||
click = acceptRequests
|
||||
)
|
||||
}
|
||||
SimpleButton(
|
||||
stringResource(R.string.delete_address),
|
||||
icon = Icons.Outlined.Delete,
|
||||
color = Color.Red,
|
||||
click = deleteAddress
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserAddressLayoutNoAddress() {
|
||||
SimpleXTheme {
|
||||
UserAddressLayout(
|
||||
userAddress = null,
|
||||
createAddress = {},
|
||||
share = { _ -> },
|
||||
acceptRequests = {},
|
||||
deleteAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserAddressLayoutAddressCreated() {
|
||||
SimpleXTheme {
|
||||
UserAddressLayout(
|
||||
userAddress = UserContactLinkRec("https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"),
|
||||
createAddress = {},
|
||||
share = { _ -> },
|
||||
acceptRequests = {},
|
||||
deleteAddress = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
-298
@@ -1,298 +0,0 @@
|
||||
package chat.simplex.app.views.usersettings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
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.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import chat.simplex.app.views.isValidDisplayName
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
|
||||
val user = chatModel.currentUser.value
|
||||
if (user != null) {
|
||||
val editProfile = rememberSaveable { mutableStateOf(false) }
|
||||
var profile by remember { mutableStateOf(user.profile.toProfile()) }
|
||||
UserProfileLayout(
|
||||
editProfile = editProfile,
|
||||
profile = profile,
|
||||
close,
|
||||
saveProfile = { displayName, fullName, image ->
|
||||
withApi {
|
||||
val newProfile = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName, fullName = fullName, image = image))
|
||||
if (newProfile != null) {
|
||||
chatModel.updateCurrentUser(newProfile)
|
||||
profile = newProfile
|
||||
}
|
||||
editProfile.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserProfileLayout(
|
||||
editProfile: MutableState<Boolean>,
|
||||
profile: Profile,
|
||||
close: () -> Unit,
|
||||
saveProfile: (String, String, String?) -> Unit,
|
||||
) {
|
||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val displayName = remember { mutableStateOf(profile.displayName) }
|
||||
val fullName = remember { mutableStateOf(profile.fullName) }
|
||||
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val profileImage = rememberSaveable { mutableStateOf(profile.image) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardState by getKeyboardState()
|
||||
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
ModalBottomSheetLayout(
|
||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
sheetContent = {
|
||||
GetImageBottomSheet(
|
||||
chosenImage,
|
||||
onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) },
|
||||
hideBottomSheet = {
|
||||
scope.launch { bottomSheetModalState.hide() }
|
||||
})
|
||||
},
|
||||
sheetState = bottomSheetModalState,
|
||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||
) {
|
||||
ModalView(close = close) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = DEFAULT_PADDING),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
AppBarTitle(stringResource(R.string.your_current_profile), false)
|
||||
Text(
|
||||
stringResource(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it),
|
||||
Modifier.padding(bottom = 24.dp),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
if (editProfile.value) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ProfileImage(192.dp, profileImage.value)
|
||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||
}
|
||||
if (profileImage.value != null) {
|
||||
DeleteImageButton { profileImage.value = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
Box {
|
||||
if (!isValidDisplayName(displayName.value)) {
|
||||
Icon(Icons.Outlined.Info, tint = Color.Red, contentDescription = stringResource(R.string.display_name_cannot_contain_whitespace))
|
||||
}
|
||||
ProfileNameTextField(displayName)
|
||||
}
|
||||
ProfileNameTextField(fullName)
|
||||
Row {
|
||||
TextButton(stringResource(R.string.cancel_verb)) {
|
||||
displayName.value = profile.displayName
|
||||
fullName.value = profile.fullName
|
||||
profileImage.value = profile.image
|
||||
editProfile.value = false
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
val saveModifier: Modifier
|
||||
val saveColor: Color
|
||||
if (enabled) {
|
||||
saveModifier = Modifier
|
||||
.clickable { saveProfile(displayName.value, fullName.value, profileImage.value) }
|
||||
saveColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
saveModifier = Modifier
|
||||
saveColor = HighOrLowlight
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.save_and_notify_contacts),
|
||||
modifier = saveModifier,
|
||||
color = saveColor
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp), contentAlignment = Alignment.Center
|
||||
) {
|
||||
ProfileImage(192.dp, profile.image)
|
||||
if (profile.image == null) {
|
||||
EditImageButton {
|
||||
editProfile.value = true
|
||||
scope.launch { bottomSheetModalState.show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
ProfileNameRow(stringResource(R.string.display_name__field), profile.displayName)
|
||||
ProfileNameRow(stringResource(R.string.full_name__field), profile.fullName)
|
||||
TextButton(stringResource(R.string.edit_verb)) { editProfile.value = true }
|
||||
}
|
||||
}
|
||||
if (savedKeyboardState != keyboardState) {
|
||||
LaunchedEffect(keyboardState) {
|
||||
scope.launch {
|
||||
savedKeyboardState = keyboardState
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileNameTextField(name: MutableState<String>) {
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
modifier = Modifier
|
||||
.padding(bottom = 24.dp)
|
||||
.padding(start = 28.dp)
|
||||
.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileNameRow(label: String, text: String) {
|
||||
Row(Modifier.padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
label,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextButton(text: String, click: () -> Unit) {
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.clickable(onClick = click),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditImageButton(click: () -> Unit) {
|
||||
IconButton(
|
||||
onClick = click,
|
||||
modifier = Modifier.background(Color(1f, 1f, 1f, 0.2f), shape = CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.PhotoCamera,
|
||||
contentDescription = stringResource(R.string.edit_image),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteImageButton(click: () -> Unit) {
|
||||
IconButton(onClick = click) {
|
||||
Icon(
|
||||
Icons.Outlined.Close,
|
||||
contentDescription = stringResource(R.string.delete_image),
|
||||
tint = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserProfileLayoutEditOff() {
|
||||
SimpleXTheme {
|
||||
UserProfileLayout(
|
||||
profile = Profile.sampleData,
|
||||
close = {},
|
||||
editProfile = remember { mutableStateOf(false) },
|
||||
saveProfile = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewUserProfileLayoutEditOn() {
|
||||
SimpleXTheme {
|
||||
UserProfileLayout(
|
||||
profile = Profile.sampleData,
|
||||
close = {},
|
||||
editProfile = remember { mutableStateOf(true) },
|
||||
saveProfile = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Android drawable generated by fa5ad-free project:
|
||||
https://github.com/diwanoczko/fa5ad-free
|
||||
|
||||
Resource generated base on Font Awesome 5 Free icons set:
|
||||
https://fontawesome.com/
|
||||
|
||||
All brand icons are trademarks of their respective owners.
|
||||
Please do not use brand logos for any purpose except to represent the
|
||||
company, product, or service to which they refer.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="23.25dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="496"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
||||
/>
|
||||
</vector>
|
||||
@@ -1,170 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -1,10 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,4H4C2.89,4 2,4.9 2,6v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V6C22,4.9 21.11,4 20,4zM20,18H4V8h16V18zM18,17h-6v-2h6V17zM7.5,17l-1.41,-1.41L8.67,13l-2.59,-2.59L7.5,9l4,4L7.5,17z"/>
|
||||
</vector>
|
||||
@@ -1,50 +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 على المنفذ 9050؟ يجب بدء تشغيل الوكيل قبل تمكين هذا الخيار.</string>
|
||||
<string name="accept_requests">قبول طلبات</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>
|
||||
@@ -1,619 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="server_connected">연결됨</string>
|
||||
<string name="server_connecting">연결 중</string>
|
||||
<string name="connect_via_group_link">그룹 링크를 통해 연결할까요\?</string>
|
||||
<string name="connect_via_invitation_link">초대 링크로 연결할까요\?</string>
|
||||
<string name="display_name_connection_established">연결 수립됨</string>
|
||||
<string name="connection_timeout">연결 시간 초과</string>
|
||||
<string name="cannot_receive_file">파일을 받을 수 없습니다</string>
|
||||
<string name="contact_already_exists">이미 추가된 연락처에요.</string>
|
||||
<string name="smp_server_test_connect">연결</string>
|
||||
<string name="connection_error_auth">연결 오류 (인증)</string>
|
||||
<string name="smp_server_test_create_queue">대기열 생성</string>
|
||||
<string name="database_initialization_error_title">데이터베이스를 초기화할 수 없어요</string>
|
||||
<string name="notifications_mode_service_desc">앱이 백그라운드에서 항상 실행돼요. 대신 메시지가 도착하자마자 바로 알림이 떠요.</string>
|
||||
<string name="notifications_mode_periodic_desc">10분마다 최대 1분간 새 메시지 확인</string>
|
||||
<string name="notification_contact_connected">연결됨</string>
|
||||
<string name="notification_preview_somebody">숨긴 대화 상대 :</string>
|
||||
<string name="notification_preview_mode_contact">대화 상대 이름</string>
|
||||
<string name="allow_verb">허용</string>
|
||||
<string name="auth_confirm_credential">자격 증명 확인</string>
|
||||
<string name="copy_verb">복사</string>
|
||||
<string name="contact_connection_pending">연결 중…</string>
|
||||
<string name="group_connection_pending">연결 중…</string>
|
||||
<string name="attach">첨부파일</string>
|
||||
<string name="icon_descr_cancel_file_preview">파일 미리보기 취소</string>
|
||||
<string name="icon_descr_context">컨텍스트 아이콘</string>
|
||||
<string name="icon_descr_server_status_connected">연결됨</string>
|
||||
<string name="back">뒤로</string>
|
||||
<string name="add_new_contact_to_create_one_time_QR_code"><b>새 연락처 추가</b> : 일회용 QR 코드 만들기</string>
|
||||
<string name="cancel_verb">취소</string>
|
||||
<string name="icon_descr_cancel_live_message">라이브 메시지 취소</string>
|
||||
<string name="choose_file">파일 선택</string>
|
||||
<string name="confirm_verb">확인</string>
|
||||
<string name="connect_via_link_or_qr">링크 / QR 코드를 통해 연결</string>
|
||||
<string name="copied">클립보드로 복사됨</string>
|
||||
<string name="create_group">비밀 그룹 생성</string>
|
||||
<string name="accept_contact_button">수락</string>
|
||||
<string name="clear_chat_warning">모든 메시지가 삭제돼요. 삭제 후 되돌릴 수 없어요! 메시지는 나에게서만 삭제돼요.</string>
|
||||
<string name="clear_verb">지우기</string>
|
||||
<string name="clear_chat_menu_action">지우기</string>
|
||||
<string name="clear_chat_button">채팅 지우기</string>
|
||||
<string name="clear_chat_question">채팅을 지울까요\?</string>
|
||||
<string name="connection_request_sent">연결 요청 완료</string>
|
||||
<string name="connect_via_link">링크를 통해 연결</string>
|
||||
<string name="smp_servers_preset_add">프리셋 서버 추가</string>
|
||||
<string name="smp_servers_add">서버 추가…</string>
|
||||
<string name="chat_console">채팅 콘솔</string>
|
||||
<string name="smp_servers_check_address">서버 주소를 확인 후 다시 시도하십시오.</string>
|
||||
<string name="configure_ICE_servers">ICE 서버 설정</string>
|
||||
<string name="contribute">기여</string>
|
||||
<string name="network_settings">고급 네트워크 설정</string>
|
||||
<string name="network_session_mode_user">채팅 프로필</string>
|
||||
<string name="network_session_mode_entity">연결</string>
|
||||
<string name="accept_requests">요청 수락</string>
|
||||
<string name="app_version_code">앱 빌드 : %s</string>
|
||||
<string name="appearance_settings">외관</string>
|
||||
<string name="app_version_title">앱 버전</string>
|
||||
<string name="app_version_name">앱 버전 : v%s</string>
|
||||
<string name="accept_automatically">자동</string>
|
||||
<string name="contact_requests">대화 상대의 요청</string>
|
||||
<string name="core_version">코어 버전 : v%s</string>
|
||||
<string name="callstatus_accepted">전화 받음</string>
|
||||
<string name="bold">굵게</string>
|
||||
<string name="callstatus_in_progress">전화 연결 중</string>
|
||||
<string name="colored">색깔</string>
|
||||
<string name="confirm_password">암호 확인</string>
|
||||
<string name="callstatus_connecting">전화 연결 중</string>
|
||||
<string name="create_profile_button">생성</string>
|
||||
<string name="create_profile">프로필 생성</string>
|
||||
<string name="callstatus_error">통화 오류</string>
|
||||
<string name="callstate_connected">연결됨</string>
|
||||
<string name="callstate_connecting">연결 중…</string>
|
||||
<string name="create_your_profile">내 프로필 생성</string>
|
||||
<string name="onboarding_notifications_mode_periodic_desc"><b>배터리에 좋음</b>. 백그라운드 서비스는 10분마다 새 메시지를 확인합니다. 전화 및 긴급 메시지를 놓칠 수 있습니다.</string>
|
||||
<string name="call_already_ended">전화가 이미 종료되었습니다!</string>
|
||||
<string name="always_use_relay">항상 릴레이 사용</string>
|
||||
<string name="icon_descr_audio_call">음성 전화</string>
|
||||
<string name="settings_audio_video_calls">음성 & 영상 전화</string>
|
||||
<string name="call_on_lock_screen">잠금 화면에서의 전화</string>
|
||||
<string name="status_contact_has_no_e2e_encryption">대화 상대와 종단간 암호화되지 않음</string>
|
||||
<string name="answer_call">응답</string>
|
||||
<string name="icon_descr_audio_on">소리 켜기</string>
|
||||
<string name="icon_descr_audio_off">소리 끄기</string>
|
||||
<string name="icon_descr_call_ended">통화 종료됨</string>
|
||||
<string name="icon_descr_call_connecting">전화 연결 중</string>
|
||||
<string name="auto_accept_images">이미지 자동 다운로드하기</string>
|
||||
<string name="integrity_msg_bad_hash">잘못된 메시지 해시</string>
|
||||
<string name="integrity_msg_bad_id">잘못된 메시지 아이디</string>
|
||||
<string name="settings_section_title_calls">전화</string>
|
||||
<string name="chat_is_running">채팅 기능이 작동하고 있어요</string>
|
||||
<string name="settings_section_title_chats">채팅</string>
|
||||
<string name="chat_database_imported">채팅 데이테베이스를 불러 왔어요</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>주의</b>: 비밀번호를 분실하면 복구나 비밀번호 변경을 할 수 없어요.</string>
|
||||
<string name="change_database_passphrase_question">데이터베이스 암호를 바꾸겠습니까\?</string>
|
||||
<string name="confirm_new_passphrase">새로운 암호 확인…</string>
|
||||
<string name="chat_archive_section">채팅 기록 보관함</string>
|
||||
<string name="rcv_group_event_changed_your_role">내 역할이 %s 역할로 변경되었습니다.</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_changing">주소 바꾸기…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing">주소 바꾸기…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing_for_member">%s의 주소 바꾸기…</string>
|
||||
<string name="rcv_group_event_member_connected">연결됨</string>
|
||||
<string name="group_member_status_complete">완료</string>
|
||||
<string name="group_member_status_connected">연결됨</string>
|
||||
<string name="group_member_status_connecting">연결 중</string>
|
||||
<string name="group_member_status_accepted">연결 중 (수락됨)</string>
|
||||
<string name="group_member_status_announced">연결 중 (알림)</string>
|
||||
<string name="group_member_status_intro_invitation">연결 중(초대 시작)</string>
|
||||
<string name="group_member_status_creator">제작자</string>
|
||||
<string name="invite_prohibited">상대를 초대할 수 없습니다</string>
|
||||
<string name="clear_contacts_selection_button">지우기</string>
|
||||
<string name="icon_descr_contact_checked">대화 상대 확인됨</string>
|
||||
<string name="create_group_link">그룹 링크 생성</string>
|
||||
<string name="button_create_group_link">링크 생성</string>
|
||||
<string name="change_member_role_question">그룹 역할을 바꾸겠습니까\?</string>
|
||||
<string name="info_row_connection">연결</string>
|
||||
<string name="users_add">프로필 추가</string>
|
||||
<string name="incognito_random_profile_description">대화 상대에게 랜덤으로 만들어진 익명 프로필이 보내져요</string>
|
||||
<string name="cant_delete_user_profile">사용자 프로필을 삭제할 수 없습니다</string>
|
||||
<string name="chat_preferences_always">항상</string>
|
||||
<string name="chat_preferences_contact_allows">대화 상대가 허용했어요.</string>
|
||||
<string name="contact_preferences">연락처 설정</string>
|
||||
<string name="allow_voice_messages_only_if">대화 상대도 허용한 경우에만 음성 메시지를 보낼 수 있습니다.</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">모두에게서 메시지 영구 삭제 허용하기.</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">대화 상대에게 자동 삭제되는 메시지 허용하기.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">상대가 음성 메시지를 보내는 것을 허용하기.</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">나와 대화 상대 모두 메시지를 영구 삭제할 수 있어요.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">나와 대화 상대 모두 음성 메시지를 보낼 수 있어요.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">상대가 메시지에 삭제 표시를 할 수 있습니다. 그러나 삭제 표시된 메시지 내용은 여전히 볼 수 있습니다.</string>
|
||||
<string name="allow_to_delete_messages">모두에게서 메시지 영구 삭제 허용하기.</string>
|
||||
<string name="feature_cancelled_item">%s 취소됨</string>
|
||||
<string name="v4_2_auto_accept_contact_requests">대화 상대의 요청 자동 수락</string>
|
||||
<string name="v4_5_transport_isolation_descr">채팅 프로필(기본값) 또는 연결(베타).</string>
|
||||
<string name="v4_4_verify_connection_security_desc">대화 상대와 보안 코드를 비교해 보세요.</string>
|
||||
<string name="v4_6_chinese_spanish_interface">중국어 및 스페인어 인터페이스</string>
|
||||
<string name="about_simplex">SimpleX에 대하여</string>
|
||||
<string name="accept">수락</string>
|
||||
<string name="share_one_time_link">일회용 초대 링크 생성</string>
|
||||
<string name="create_address">주소 생성</string>
|
||||
<string name="chat_item_ttl_day">1일</string>
|
||||
<string name="about_simplex_chat"><xliff:g id="appNameFull">SimpleX</xliff:g>에 대하여</string>
|
||||
<string name="color_primary">강조 색상</string>
|
||||
<string name="accept_call_on_lock_screen">응답</string>
|
||||
<string name="accept_connection_request__question">연결 요청을 수락할까요\?</string>
|
||||
<string name="accept_feature">수락</string>
|
||||
<string name="network_enable_socks_info">SOCKS 프록시(포트 9050)를 통해 서버에 액세스할까요\? 이 설정을 활성화하기 전에 프록시를 시작해야 해요.</string>
|
||||
<string name="smp_servers_add_to_another_device">다른 기기에 추가</string>
|
||||
<string name="v4_3_improved_server_configuration_desc">QR 코드 스캔으로 서버 추가</string>
|
||||
<string name="button_add_welcome_message">환영 메시지 추가</string>
|
||||
<string name="group_member_role_admin">관리자</string>
|
||||
<string name="v4_2_group_links_desc">관리자는 그룹 가입을 위한 링크를 만들 수 있어요.</string>
|
||||
<string name="allow_to_send_disappearing">자동 삭제되는 메시지 허용하기.</string>
|
||||
<string name="users_delete_all_chats_deleted">모든 채팅과 메시지가 삭제돼요. 삭제 후 되돌릴 수 없어요!</string>
|
||||
<string name="allow_to_send_voice">음성 메시지 허용하기.</string>
|
||||
<string name="allow_voice_messages_question">음성 메시지를 허용하겠습니까\?</string>
|
||||
<string name="allow_disappearing_messages_only_if">상대도 허용하는 경우에만 자동 삭제되는 메시지를 사용할 수 있어요.</string>
|
||||
<string name="allow_direct_messages">그룹 멤버에게 1:1 채팅 허용하기.</string>
|
||||
<string name="all_group_members_will_remain_connected">모든 그룹 멤버는 연결 상태가 계속 유지돼요.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">상대도 허용한 경우에만 모두에게서 메시지 영구 삭제가 가능해요.</string>
|
||||
<string name="all_your_contacts_will_remain_connected">모든 연락처와 연결 상태가 계속 유지돼요.</string>
|
||||
<string name="notifications_mode_service">항상 켜기</string>
|
||||
<string name="keychain_is_storing_securely">안드로이드 암호 저장소는 비밀번호를 안전하게 저장하는 데 사용되고 알림이 작동하도록 해요.</string>
|
||||
<string name="keychain_allows_to_receive_ntfs">안드로이드 암호 저장소는 앱을 다시 시작하거나 비밀번호 변경을 하고 나서 비밀번호를 안전하게 저장하는 데 사용되고 알림이 작동되도록 해요.</string>
|
||||
<string name="notifications_mode_off_desc">앱이 실행 중일 때만 알림을 받을 수 있고 백그라운드에서 실행되지 않아요.</string>
|
||||
<string name="full_backup">앱 데이터 백업</string>
|
||||
<string name="settings_section_title_icon">앱 아이콘</string>
|
||||
<string name="incognito_random_profile_from_contact_description">링크를 보낸 사람한테 랜덤으로 만들어진 익명 프로필이 보내져요</string>
|
||||
<string name="network_session_mode_user_description">별도로 분리된 TCP 연결(그리고 SOCKS 자격 증명)이 <b>각각의 채팅 프로필</b>에 사용될 거예요.</string>
|
||||
<string name="network_session_mode_entity_description">별도로 분리된 TCP 연결(및 SOCKS 자격 증명)이 <b>각각의 연락처 및 그룹 구성원</b>에게 사용될 거예요.
|
||||
\n<b>참고</b>: 연결이 많은 경우 배터리 및 트래픽 소비가 엄청 높을 수 있고 일부 연결이 실패할 수 있어요.</string>
|
||||
<string name="icon_descr_asked_to_receive">이미지 수신 요청됨</string>
|
||||
<string name="v4_6_audio_video_calls">음성 및 영상 전화</string>
|
||||
<string name="audio_call_no_encryption">음성 전화 (종단간 암호화 X)</string>
|
||||
<string name="auth_unavailable">인증할 수 없어요</string>
|
||||
<string name="turning_off_service_and_periodic">새 메시지를 수신하기 위해 배터리 최적화 설정을 바꿉니다. 설정에서 언제든지 다시 바꿀 수 있습니다.</string>
|
||||
<string name="onboarding_notifications_mode_off_desc"><b>배터리에 가장 좋음</b>. 앱이 실행 중일 때만 알림을 받게 되며 백그라운드에서 실행되지 않습니다.</string>
|
||||
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>설정을 통해 비활성화할 수 있어요.</b> – 앱이 실행되는 동안 알림이 표시되요.</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">나와 대화 상대 모두 자동 삭제되는 메시지를 보낼 수 있어요.</string>
|
||||
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>QR 코드 스캔</b>: QR 코드를 보여주는 사람과 연결해요.</string>
|
||||
<string name="cannot_access_keychain">데이터베이스 암호를 저장하고 있는 암호키 저장소에 접근할 수 없습니다</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>배터리 많이 사용</b>! 백그라운드에서 항상 실행돼요. 메시지를 수신하자마자 알림이 떠요.</string>
|
||||
<string name="callstatus_ended">통화 종료됨 <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="callstatus_calling">전화 중…</string>
|
||||
<string name="icon_descr_call_progress">전화 연결 중</string>
|
||||
<string name="icon_descr_cancel_link_preview">링크 미리보기 취소</string>
|
||||
<string name="icon_descr_cancel_image_preview">이미지 미리보기 취소</string>
|
||||
<string name="rcv_group_event_changed_member_role">%s 역할에서 %s 역할로 변경되었습니다</string>
|
||||
<string name="chat_database_section">채팅 데이터베이스</string>
|
||||
<string name="alert_title_cant_invite_contacts">대화 상대를 초대할 수 없습니다!</string>
|
||||
<string name="change_verb">변경</string>
|
||||
<string name="chat_archive_header">채팅 기록 보관함</string>
|
||||
<string name="change_role">역할 바꾸기</string>
|
||||
<string name="chat_database_deleted">채팅 데이터베이스를 삭제했어요</string>
|
||||
<string name="chat_is_stopped">채팅 기능이 멈췄어요</string>
|
||||
<string name="chat_is_stopped_indication">채팅 기능이 멈췄어요</string>
|
||||
<string name="chat_preferences">채팅 설정</string>
|
||||
<string name="chat_with_developers">개발자와 대화</string>
|
||||
<string name="connect_via_link_verb">연결</string>
|
||||
<string name="display_name_connecting">연결 중…</string>
|
||||
<string name="icon_descr_close_button">닫기 버튼</string>
|
||||
<string name="connect_button">연결</string>
|
||||
<string name="group_member_status_introduced">연결 중 (도입)</string>
|
||||
<string name="connection_error">연결 오류</string>
|
||||
<string name="connection_local_display_name">연결 <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
|
||||
<string name="connect_via_contact_link">링크를 통해 연결하겠습니까\?</string>
|
||||
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">대화 상대와 메시지가 삭제돼요. 삭제 후 되돌릴 수 없어요!</string>
|
||||
<string name="status_contact_has_e2e_encryption">대화 상대와 종단간 암호화됨</string>
|
||||
<string name="alert_title_contact_connection_pending">대화 상대와 아직 연결되지 않았어요!</string>
|
||||
<string name="core_build_timestamp">코어 빌드: %s</string>
|
||||
<string name="archive_created_on_ts"><xliff:g id="archive_ts">%1$s</xliff:g>에 생성 완료</string>
|
||||
<string name="create_one_time_link">일회용 초대 링크 생성</string>
|
||||
<string name="create_secret_group_title">비밀 그룹 생성</string>
|
||||
<string name="accept_contact_incognito_button">익명 수락</string>
|
||||
<string name="chat_item_ttl_month">1개월</string>
|
||||
<string name="chat_item_ttl_week">1주</string>
|
||||
<string name="a_plus_b">a + b</string>
|
||||
<string name="deleted_description">삭제됨</string>
|
||||
<string name="simplex_link_mode_description">설명</string>
|
||||
<string name="smp_server_test_delete_queue">대기열 삭제</string>
|
||||
<string name="delete_verb">삭제</string>
|
||||
<string name="delete_message__question">메시지를 삭제할까요\?</string>
|
||||
<string name="for_me_only">나에게서만 삭제</string>
|
||||
<string name="delete_member_message__question">멤버의 메시지를 삭제할까요\?</string>
|
||||
<string name="maximum_supported_file_size">현재 지원되는 최대 파일 크기는 <xliff:g id="maxFileSize">%1$s</xliff:g>입니다.</string>
|
||||
<string name="image_decoding_exception_title">디코딩 오류</string>
|
||||
<string name="button_delete_contact">대화 상대 삭제</string>
|
||||
<string name="delete_contact_question">연락처를 삭제할까요\?</string>
|
||||
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 데스크톱: <b>QR 코드 스캔</b>을 통해 앱에서 표시된 QR 코드를 스캔해 주세요.</string>
|
||||
<string name="delete_contact_menu_action">삭제</string>
|
||||
<string name="delete_group_menu_action">삭제</string>
|
||||
<string name="delete_pending_connection__question">대기 중인 연결을 삭제할까요\?</string>
|
||||
<string name="clear_verification">인증 취소</string>
|
||||
<string name="database_passphrase_and_export">데이터베이스 비밀번호 & 내보내기</string>
|
||||
<string name="smp_servers_delete_server">서버 삭제</string>
|
||||
<string name="delete_address">주소 삭제</string>
|
||||
<string name="delete_address__question">주소를 삭제할까요\?</string>
|
||||
<string name="delete_image">이미지 삭제</string>
|
||||
<string name="decentralized">탈중앙화</string>
|
||||
<string name="settings_section_title_develop">개발</string>
|
||||
<string name="settings_developer_tools">개발자 도구</string>
|
||||
<string name="settings_section_title_device">기기</string>
|
||||
<string name="database_passphrase">데이터베이스 비밀번호</string>
|
||||
<string name="delete_files_and_media_for_all_users">모든 채팅 프로필 파일 삭제</string>
|
||||
<string name="database_error">데이터베이스 에러</string>
|
||||
<string name="passphrase_is_different">데이터베이스 비밀번호가 암호 저장소에 저장된 것과 일치하지 않습니다.</string>
|
||||
<string name="database_passphrase_is_required">채팅을 열려면 데이터베이스 비밀번호가 필요해요.</string>
|
||||
<string name="delete_archive">보관된 채팅 삭제</string>
|
||||
<string name="delete_chat_archive_question">보관된 채팅을 삭제할까요\?</string>
|
||||
<string name="num_contacts_selected">%d 개의 연락처가 선택되었습니다.</string>
|
||||
<string name="info_row_database_id">데이터베이스 아이디</string>
|
||||
<string name="users_delete_profile_for">다음 채팅 프로필 삭제</string>
|
||||
<string name="theme_dark">어둡게</string>
|
||||
<string name="delete_after">다음 기간 이후 자동 삭제</string>
|
||||
<string name="above_then_preposition_continuation">위 다음 :</string>
|
||||
<string name="delete_database">데이터베이스 삭제</string>
|
||||
<string name="set_password_to_export_desc">데이터베이스는 임의의 비밀번호로 암호화되었습니다. 내보내기 기능 사용 전 비밀번호를 변경해 주세요.</string>
|
||||
<string name="delete_files_and_media_question">파일과 미디어를 삭제할까요\?</string>
|
||||
<string name="current_passphrase">현재 비밀번호…</string>
|
||||
<string name="database_encrypted">데이터베이스 암호화 완료!</string>
|
||||
<string name="database_passphrase_will_be_updated">데이터베이스 비밀번호가 업데이트되요.</string>
|
||||
<string name="encrypted_with_random_passphrase">데이터베이스는 임의의 비밀번호로 암호화되었고, 원하시면 비밀번호를 변경할 수 있어요.</string>
|
||||
<string name="database_will_be_encrypted">데이터베이스는 암호화될 거예요.</string>
|
||||
<string name="delete_messages">메시지 삭제</string>
|
||||
<string name="delete_messages_after">다음 기간 이후 자동 삭제</string>
|
||||
<string name="rcv_group_event_group_deleted">삭제된 그룹</string>
|
||||
<string name="delete_link">링크 삭제</string>
|
||||
<string name="delete_link_question">링크를 삭제할까요\?</string>
|
||||
<string name="chat_preferences_default">기본값 (%s)</string>
|
||||
<string name="ttl_day">%d일</string>
|
||||
<string name="ttl_d">%d일</string>
|
||||
<string name="ttl_days">%d일</string>
|
||||
<string name="button_delete_group">그룹 삭제</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_completed">주소가 변경되었습니다.</string>
|
||||
<string name="database_encryption_will_be_updated">데이터베이스 비밀번호가 업데이트되고 암호 저장소에 보관됩니다.</string>
|
||||
<string name="database_will_be_encrypted_and_passphrase_stored">데이터베이스는 암호화되고, 비밀번호는 암호 저장소에 보관될 거에요.</string>
|
||||
<string name="users_delete_question">채팅 프로필을 삭제할까요\?</string>
|
||||
<string name="delete_files_and_media_all">모든 파일 삭제</string>
|
||||
<string name="delete_chat_profile_question">채팅 프로필을 삭제할까요\?</string>
|
||||
<string name="full_deletion">모두에게서 삭제</string>
|
||||
<string name="delete_group_question">그룹을 삭제할까요\?</string>
|
||||
<string name="failed_to_create_user_duplicate_title">표시 이름이 중복되어요!</string>
|
||||
<string name="smp_server_test_disconnect">연결 끊기</string>
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">기기 인증이 비활성화되어 SimpleX 잠금 기능이 작동하지 않아요.</string>
|
||||
<string name="auth_disable_simplex_lock">SimpleX 잠금 비활성화</string>
|
||||
<string name="icon_descr_server_status_disconnected">연결 끊김</string>
|
||||
<string name="add_contact">일회용 초대 링크</string>
|
||||
<string name="add_contact_or_create_group">새로운 채팅 시작</string>
|
||||
<string name="display_name__field">표시 이름</string>
|
||||
<string name="display_name_cannot_contain_whitespace">표시 이름에는 공백문자가 쓰일 수 없어요.</string>
|
||||
<string name="display_name">표시 이름</string>
|
||||
<string name="encrypted_audio_call">종단간 암호화된 음성 전화</string>
|
||||
<string name="encrypted_video_call">종단간 암호화된 영상 전화</string>
|
||||
<string name="no_call_on_lock_screen">비활성화</string>
|
||||
<string name="status_e2e_encrypted">종단간 암호화</string>
|
||||
<string name="integrity_msg_duplicate">중복된 메시지</string>
|
||||
<string name="accept_feature_set_1_day">1일로 설정</string>
|
||||
<string name="v4_4_disappearing_messages">자동 삭제되는 메시지</string>
|
||||
<string name="total_files_count_and_size">전체 크기가 %s인 %d개의 파일</string>
|
||||
<string name="conn_level_desc_direct">다이렉트</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">이 채팅에서는 자동 삭제되는 메시지를 사용할 수 없어요.</string>
|
||||
<string name="disappearing_messages_are_prohibited">이 그룹에서는 자동 삭제되는 메시지를 사용할 수 없어요.</string>
|
||||
<string name="ttl_m">%d분</string>
|
||||
<string name="ttl_months">%d 개월</string>
|
||||
<string name="ttl_min">%d 분</string>
|
||||
<string name="ttl_month">%d 개월</string>
|
||||
<string name="ttl_week">%d 주</string>
|
||||
<string name="downgrade_and_open_chat">다운그레이드하고 채팅 열기</string>
|
||||
<string name="direct_messages">1:1 메시지</string>
|
||||
<string name="timed_messages">자동 삭제되는 메시지</string>
|
||||
<string name="direct_messages_are_prohibited_in_chat">이 그룹에서는 멤버들의 1:1 채팅이 금지되어 있어요.</string>
|
||||
<string name="ttl_s">%d초</string>
|
||||
<string name="ttl_sec">%d 초</string>
|
||||
<string name="ttl_h">%d시</string>
|
||||
<string name="ttl_mth">%d개월</string>
|
||||
<string name="ttl_w">%d주</string>
|
||||
<string name="ttl_weeks">%d 주</string>
|
||||
<string name="confirm_database_upgrades">데이터베이스 업그레이드 확인</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">기기 인증을 하고 있지 않아요. 기기 인증을 켜면 설정에서 SimpleX 잠금 기능을 사용할 수 있어요.</string>
|
||||
<string name="ttl_hour">%d 시간</string>
|
||||
<string name="ttl_hours">%d 시간</string>
|
||||
<string name="mtr_error_different">앱/데이터베이스의 다른 마이그레이션: %s / %s</string>
|
||||
<string name="v4_5_multiple_chat_profiles_descr">다른 이름, 아바타 그리고 전송 격리.</string>
|
||||
<string name="dont_show_again">다시 보지 않기</string>
|
||||
<string name="connected_to_server_to_receive_messages_from_contact">이 대화 상대로부터의 메시지를 수신할 서버와 연결되었어요.</string>
|
||||
<string name="app_name"><xliff:g id="appName">SimpleX</xliff:g></string>
|
||||
<string name="contact_developers">앱 업데이트 후 개발자에게 연락해 주세요.</string>
|
||||
<string name="connection_error_auth_desc">대화 상대가 나갔거나 초대 링크가 이미 사용된 경우가 아니면 버그일 수 있어요. 이 경우 개발자에게 알려주세요.
|
||||
\n대화 상대에게 다른 초대 링크 만들도록 부탁하고 네트워크 연결이 안정적인지 확인하세요.</string>
|
||||
<string name="auth_enable_simplex_lock">SimpleX 잠금 활성화</string>
|
||||
<string name="auth_log_in_using_credential">자격 증명으로 로그인</string>
|
||||
<string name="auth_open_chat_console">채팅 콘솔 열기</string>
|
||||
<string name="auth_stop_chat">채팅 중지하기</string>
|
||||
<string name="auth_unlock">잠금 해제하기</string>
|
||||
<string name="auth_you_will_be_required_to_authenticate_when_you_start_or_resume">앱을 사용하지 않는 지 30초가 지나면 다시 인증해야 해요.</string>
|
||||
<string name="contact_wants_to_connect_with_you">님이 연결하고 싶어해요!</string>
|
||||
<string name="callstate_starting">시작…</string>
|
||||
<string name="callstate_waiting_for_answer">응답 대기 중…</string>
|
||||
<string name="callstate_waiting_for_confirmation">확인 대기 중…</string>
|
||||
<string name="alert_title_skipped_messages">읽지 않는 메시지</string>
|
||||
<string name="alert_title_cant_invite_contacts_descr">이 그룹에서 익명 프로필을 사용하고 있어요. 내 원래 프로필이 노출되는 걸 방지하기 위해 대화 상대 초대가 허용되지 않아요.</string>
|
||||
<string name="button_remove_member">멤버 삭제하기</string>
|
||||
<string name="chat_item_ttl_seconds">%s 초</string>
|
||||
<string name="alert_message_group_invitation_expired">이 링크로 참여할 수 없어요. 이미 삭제된 링크에요.</string>
|
||||
<string name="alert_message_no_group">존재하지 않는 그룹이에요.</string>
|
||||
<string name="alert_title_no_group">그룹을 찾을 수 없어요!</string>
|
||||
<string name="button_add_members">맴버 초대하기</string>
|
||||
<string name="button_welcome_message">환영 메시지</string>
|
||||
<string name="button_edit_group_profile">그룹 프로필 수정</string>
|
||||
<string name="button_leave_group">그룹 나가기</string>
|
||||
<string name="button_send_direct_message">1:1 채팅 시작하기</string>
|
||||
<string name="conn_stats_section_title_servers">서버</string>
|
||||
<string name="conn_level_desc_indirect">인다이렉트 (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
|
||||
<string name="chat_preferences_you_allow">허용함</string>
|
||||
<string name="chat_preferences_off">꺼짐</string>
|
||||
<string name="chat_preferences_on">켜짐</string>
|
||||
<string name="chat_preferences_no">아니요</string>
|
||||
<string name="ask_your_contact_to_enable_voice">대화 상대에게 음성 메시지 기능을 활성화 해달라고 부탁해보세요.</string>
|
||||
<string name="chat_help_tap_button">탭 버튼</string>
|
||||
<string name="connection_you_accepted_will_be_cancelled">수락한 연결이 취소됩니다!</string>
|
||||
<string name="chat_lock">SimpleX 잠금</string>
|
||||
<string name="callstatus_rejected">거절된 전화</string>
|
||||
<string name="callstate_ended">종료됨</string>
|
||||
<string name="call_connection_peer_to_peer">P2P</string>
|
||||
<string name="call_connection_via_relay">릴레이를 경유</string>
|
||||
<string name="alert_title_group_invitation_expired">만료된 초대 링크에요!</string>
|
||||
<string name="chat_preferences_yes">네</string>
|
||||
<string name="contact_wants_to_connect_via_call"><xliff:g id="contactName" example="Alice">%1$s</xliff:g>님이 다음을 통해 연결하려고 해요 :</string>
|
||||
<string name="allow_accepting_calls_from_lock_screen">설정에서 잠금 화면에서 바로 전화를 받을 수 있도록 설정할 수 있어요.</string>
|
||||
<string name="alert_text_connection_pending_they_need_to_be_online_can_delete_and_retry">연결을 완료하려면 대화 상대가 온라인 상태여야 해요.
|
||||
\n연결 요청을 취소하고 대화 상대를 삭제할 수 있어요 (그리고 새 링크로 재시도).</string>
|
||||
<string name="alert_text_skipped_messages_it_can_happen_when">다음과 같은 경우에 발생할 수 있어요.
|
||||
\n1. 대화 상대가 메시지를 보낸 지 30일 지나서 서버에서 삭제된 경우
|
||||
\n2. 메시지를 수신하는 데 사용된 서버가 업데이트되고 재부팅된 경우
|
||||
\n3. 침해된 연결의 경우
|
||||
\n서버 업데이트를 받으려면 설정을 통해 개발자에게 연락해 주세요.
|
||||
\n저희 개발팀은 메시지 손실을 방지하기 위해 중복된 서버를 추가할 예정이에요.</string>
|
||||
<string name="auth_simplex_lock_turned_on">SimpleX 잠금 켜짐</string>
|
||||
<string name="callstate_received_answer">응답됨…</string>
|
||||
<string name="callstate_received_confirmation">확인 받음…</string>
|
||||
<string name="callstatus_missed">부재 중 전화</string>
|
||||
<string name="chat_item_ttl_none">사용 안 함</string>
|
||||
<string name="chat_with_the_founder">질문이나 아이디어 보내기</string>
|
||||
<string name="connect_via_link_or_qr_from_clipboard_or_in_person">(스캔하거나 클립보드에서 붙여넣기)</string>
|
||||
<string name="contact_sent_large_file">대화 상대가 현재 지원되는 최대 크기(<xliff:g id="maxFileSize">%1$s</xliff:g>)보다 큰 파일을 보냈습니다.</string>
|
||||
<string name="display_name_invited_to_connect">초대를 받았어요.</string>
|
||||
<string name="failed_to_create_user_title">프로필 생성 오류!</string>
|
||||
<string name="description_via_group_link_incognito">그룹 링크로 익명 채팅</string>
|
||||
<string name="description_via_group_link">그룹 링크로 채팅</string>
|
||||
<string name="description_via_one_time_link">일회용 링크로 채팅</string>
|
||||
<string name="description_you_shared_one_time_link_incognito">일회용 익명 연락처를 공유했어요.</string>
|
||||
<string name="description_you_shared_one_time_link">일회용 프로필 연락처를 공유했어요.</string>
|
||||
<string name="description_via_contact_address_link_incognito">상대의 연락처 링크로 익명 채팅</string>
|
||||
<string name="description_via_contact_address_link">상대의 연락처 링크로 채팅</string>
|
||||
<string name="description_via_one_time_link_incognito">일회용 연락처로 익명 채팅</string>
|
||||
<string name="ensure_smp_server_address_are_correct_format_and_unique">SMP 서버 주소가 올바른 형식이고 줄로 구분되어 있고 중복이 없는지 확인해 주세요.</string>
|
||||
<string name="error_saving_smp_servers">SMP 서버 저장 오류</string>
|
||||
<string name="error_setting_network_config">네트워크 설정 업데이트 오류</string>
|
||||
<string name="failed_to_active_user_title">프로필 변경 오류!</string>
|
||||
<string name="failed_to_create_user_duplicate_desc">동일한 표시 이름을 가진 채팅 프로필이 있어요. 다른 이름을 선택해 주세요.</string>
|
||||
<string name="failed_to_parse_chats_title">채팅 불러오기 실패</string>
|
||||
<string name="failed_to_parse_chat_title">채팅 불러오기 실패</string>
|
||||
<string name="error_adding_members">멤버 추가 오류</string>
|
||||
<string name="error_joining_group">그룹 참여 오류</string>
|
||||
<string name="error_sending_message">메시지 전송 오류</string>
|
||||
<string name="error_creating_address">주소 생성 오류</string>
|
||||
<string name="error_receiving_file">파일 다운로드 오류</string>
|
||||
<string name="error_accepting_contact_request">상대 요청 수락 오류</string>
|
||||
<string name="error_changing_address">주소 변경 오류</string>
|
||||
<string name="error_deleting_contact">연락처 삭제 오류</string>
|
||||
<string name="error_deleting_contact_request">대화 요청 삭제 오류</string>
|
||||
<string name="error_deleting_group">그룹 삭제 오류</string>
|
||||
<string name="error_deleting_pending_contact_connection">대기 중 대화 요청 삭제 오류</string>
|
||||
<string name="error_smp_test_certificate">서버 주소의 인증서의 지문(fingerprint)이 잘못되었을 수도 있어요.</string>
|
||||
<string name="error_smp_test_failed_at_step">테스트가 %s단계에서 실패했어요.</string>
|
||||
<string name="error_smp_test_server_auth">서버는 대기열을 생성하고 비밀번호를 확인하려면 인증이 필요해요.</string>
|
||||
<string name="enter_passphrase_notification_desc">알림을 받으려면 데이터베이스 암호를 입력해 주세요.</string>
|
||||
<string name="enter_passphrase_notification_title">비밀번호가 필요해요.</string>
|
||||
<string name="error_deleting_user">프로필 삭제 오류</string>
|
||||
<string name="error_updating_user_privacy">사용자 개인정보 업데이트 오류</string>
|
||||
<string name="database_initialization_error_desc">데이터베이스가 올바르게 작동하지 안하요. 자세히 알아보려면 탭하세요.</string>
|
||||
<string name="edit_verb">수정하기</string>
|
||||
<string name="delete_message_cannot_be_undone_warning">메시지가 삭제돼요. 삭제 후 복구할 수 없어요!</string>
|
||||
<string name="delete_message_mark_deleted_warning">메시지가 삭제 표시될 거예요. 대화 상대는 여전히 삭제된 내용을 볼 수 있어요.</string>
|
||||
<string name="ensure_ICE_server_address_are_correct_format_and_unique">WebRTC ICE 서버 주소가 올바른 형식이고 줄로 구분되고 중복이 없는지 확인해 주세요.</string>
|
||||
<string name="enter_one_ICE_server_per_line">ICE 서버(한 줄에 하나씩)</string>
|
||||
<string name="error_saving_ICE_servers">ICE 서버 저장 오류</string>
|
||||
<string name="core_simplexmq_version">simplexmq: v%s (%2s)</string>
|
||||
<string name="exit_without_saving">저장하지 않고 나가기</string>
|
||||
<string name="encrypt_database">암호화</string>
|
||||
<string name="encrypted_database">암호화된 데이터베이스</string>
|
||||
<string name="feature_off">꺼짐</string>
|
||||
<string name="feature_enabled_for_you">나에게 켜짐</string>
|
||||
<string name="feature_enabled_for_contact">대화 상대에게 켜짐</string>
|
||||
<string name="feature_enabled">켜짐</string>
|
||||
<string name="feature_received_prohibited">수신됨, 금지됨</string>
|
||||
<string name="export_database">데이터베이스 내보내기</string>
|
||||
<string name="enable_automatic_deletion_question">자동 삭제되는 메시지를 사용할까요\?</string>
|
||||
<string name="error_changing_message_deletion">설정 변경 오류</string>
|
||||
<string name="enable_automatic_deletion_message">이 작업은 되돌릴 수 없어요. 선택한 시간보다 일찍 보내거나 받은 메시지는 삭제돼요. 이는 몇 분 걸릴 수 있어요.</string>
|
||||
<string name="error_with_info">오류: %s</string>
|
||||
<string name="enter_correct_passphrase">올바른 비밀번호를 입력해 주세요.</string>
|
||||
<string name="database_backup_can_be_restored">데이터베이스 비밀번호 변경이 완료되지 않았어요.</string>
|
||||
<string name="database_restore_error">데이터베이스 오류 복구</string>
|
||||
<string name="error_creating_link_for_group">그룹 링크 생성 오류</string>
|
||||
<string name="error_updating_link_for_group">그룹 링크 업데이트 오류</string>
|
||||
<string name="error_changing_role">역할 변경 오류</string>
|
||||
<string name="error_removing_member">멤버 삭제 오류</string>
|
||||
<string name="database_downgrade">데이터베이스 다운그레이드</string>
|
||||
<string name="database_migrations">마이그레이션: %s</string>
|
||||
<string name="delete_group_for_all_members_cannot_undo_warning">모든 멤버에게서 그룹이 삭제돼요. 삭제 후 복구할 수 없어요!</string>
|
||||
<string name="delete_group_for_self_cannot_undo_warning">나에게서만 그룹이 삭제되요. 삭제 후 복구할 수 없어요!</string>
|
||||
<string name="file_not_found">파일을 찾을 수 없음</string>
|
||||
<string name="error_saving_user_password">사용자 비밀번호 저장 오류</string>
|
||||
<string name="error_stopping_chat">채팅 정지하기 오류</string>
|
||||
<string name="error_exporting_chat_database">채팅 데이터베이스 내보내기 오류</string>
|
||||
<string name="database_is_not_encrypted">채팅 데이터베이스가 암호화되지 않았어요. 비밀번호를 설정하여 보호해 주세요.</string>
|
||||
<string name="enter_passphrase">비밀번호를 입력해 주세요…</string>
|
||||
<string name="enter_password_to_show">검색에 비밀번호 입력</string>
|
||||
<string name="edit_image">이미지 수정하기</string>
|
||||
<string name="delete_chat_profile_action_cannot_be_undone_warning">이 작업은 실행 취소될 수 없어요. 프로필, 연락처, 메시지 및 파일이 영구적으로 손실돼요.</string>
|
||||
<string name="error_importing_database">채팅 데이터베이스 가져오기 오류</string>
|
||||
<string name="encrypt_database_question">데이터베이스를 암호화할까요\?</string>
|
||||
<string name="developer_options">데이터베이스 ID 및 전송 격리 옵션.</string>
|
||||
<string name="error_starting_chat">채팅 시작하기 오류</string>
|
||||
<string name="error_encrypting_database">데이터베이스 암호화 오류</string>
|
||||
<string name="enter_correct_current_passphrase">올바른 현재 비밀번호를 입력해 주세요.</string>
|
||||
<string name="delete_chat_profile">채팅 프로필 삭제</string>
|
||||
<string name="delete_profile">프로필 삭제</string>
|
||||
<string name="database_downgrade_warning">경고: 일부 데이터가 손실될 수 있어요!</string>
|
||||
<string name="database_upgrade">데이터베이스 업그레이드</string>
|
||||
<string name="delete_files_and_media_desc">이 작업은 실행 취소될 수 없어요. 수신 및 전송된 모든 파일과 미디어가 삭제돼요. 저해상도 사진만 삭제되지 않아요.</string>
|
||||
<string name="error_deleting_database">채팅 데이터베이스 삭제 오류</string>
|
||||
<string name="error_deleting_link_for_group">그룹 링크 삭제 오류</string>
|
||||
<string name="error_saving_file">파일 저장 오류</string>
|
||||
<string name="error_saving_group_profile">그룹 프로필 저장 오류</string>
|
||||
<string name="feature_offered_item">%s 제안</string>
|
||||
<string name="feature_offered_item_with_param">%s 제안: %2s</string>
|
||||
<string name="icon_descr_instant_notifications">즉시 알림</string>
|
||||
<string name="hide_notification">숨김</string>
|
||||
<string name="hide_verb">숨기기</string>
|
||||
<string name="for_everybody">모두에게</string>
|
||||
<string name="icon_descr_edited">수정됨</string>
|
||||
<string name="icon_descr_image_snd_complete">이미지 보냄</string>
|
||||
<string name="full_name__field">이름 :</string>
|
||||
<string name="hide_profile">프로필 숨기기</string>
|
||||
<string name="icon_descr_flip_camera">카메라 전환</string>
|
||||
<string name="icon_descr_hang_up">전화 끊기</string>
|
||||
<string name="file_with_path">파일 : %s</string>
|
||||
<string name="group_invitation_tap_to_join">탭하여 참여</string>
|
||||
<string name="group_invitation_tap_to_join_incognito">탭하여 익명으로 참여</string>
|
||||
<string name="group_member_status_left">나감</string>
|
||||
<string name="group_member_role_member">멤버</string>
|
||||
<string name="group_member_role_owner">소유자</string>
|
||||
<string name="group_member_status_group_deleted">그룹 삭제됨</string>
|
||||
<string name="group_member_status_invited">초대됨</string>
|
||||
<string name="group_member_status_removed">삭제됨</string>
|
||||
<string name="icon_descr_expand_role">역할 선택지 펼치기</string>
|
||||
<string name="files_and_media_section">파일 & 미디어</string>
|
||||
<string name="group_invitation_item_description">그룹으로 초대 <xliff:g id="group_name">%1$s</xliff:g></string>
|
||||
<string name="group_link">그룹 링크</string>
|
||||
<string name="group_welcome_title">환영 메시지</string>
|
||||
<string name="group_display_name_field">보여지는 그룹 이름</string>
|
||||
<string name="group_full_name_field">그룹 이름 :</string>
|
||||
<string name="group_is_decentralized">그룹은 완전히 탈중앙화되어 있으며 구성원만 그룹을 볼 수 있어요.</string>
|
||||
<string name="group_unsupported_incognito_main_profile_sent">여기에서는 시크릿 모드가 지원되지 않아요. 기본 프로필이 그룹 멤버들에게 전송될 거예요.</string>
|
||||
<string name="group_main_profile_sent">프로필이 그룹 구성원에게 전송될 거예요.</string>
|
||||
<string name="group_profile_is_stored_on_members_devices">그룹 프로필은 서버가 아닌 멤버들의 기기에 저장되어요.</string>
|
||||
<string name="group_preferences">그룹 설정</string>
|
||||
<string name="group_members_can_send_disappearing">그룹 구성원은 자동 삭제되는 메시지를 보낼 수 있어요.</string>
|
||||
<string name="group_members_can_send_dms">그룹 멤버들끼리 1:1 채팅을 할 수 있어요.</string>
|
||||
<string name="icon_descr_add_members">멤버 초대하기</string>
|
||||
<string name="icon_descr_group_inactive">비활성 그룹</string>
|
||||
<string name="group_member_role_observer">관찰자</string>
|
||||
<string name="group_info_member_you">나 : <xliff:g id="group_info_you">%1$s</xliff:g></string>
|
||||
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> 멤버</string>
|
||||
<string name="file_saved">파일 저장됨</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">대화 상대가 업로드를 완료하면 파일이 저장되어요.</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">대화 상대가 온라인 상태일 때 파일이 전송되어요. 대화 상대가 온라인이 되기를 기다리거나 나중에 다시 확인해 주세요!</string>
|
||||
<string name="icon_descr_record_voice_message">음성 메시지 녹화하기</string>
|
||||
<string name="from_gallery_button">갤러리에서</string>
|
||||
<string name="icon_descr_profile_image_placeholder">프로필 이미지 플레이스 홀더</string>
|
||||
<string name="icon_descr_address"><xliff:g id="appName">SimpleX</xliff:g> 주소</string>
|
||||
<string name="how_to">설명서</string>
|
||||
<string name="how_to_use_your_servers">내 서버 사용법</string>
|
||||
<string name="how_to_use_markdown">마크다운 사용법</string>
|
||||
<string name="how_simplex_works"><xliff:g id="appName">SimpleX</xliff:g> 작동 방식</string>
|
||||
<string name="group_invitation_expired">그룹 초대가 만료되었어요.</string>
|
||||
<string name="group_members_can_delete">그룹 멤버는 보낸 메시지를 영구 삭제할 수 있어요.</string>
|
||||
<string name="group_members_can_send_voice">그룹 멤버는 음성 메시지를 보낼 수 있어요.</string>
|
||||
<string name="hidden_profile_password">숨긴 프로필 비밀번호</string>
|
||||
<string name="full_name_optional__prompt">이름 (선택 사항)</string>
|
||||
<string name="how_it_works">작동 방식</string>
|
||||
<string name="hide_dev_options">숨기기 :</string>
|
||||
<string name="cancel_file__question">파일 전송을 취소할까요\?</string>
|
||||
<string name="file_transfer_will_be_cancelled_warning">파일 전송이 취소될 거예요. 이미 전송이 시작되었다면 중지될 거예요.</string>
|
||||
<string name="icon_descr_sent_msg_status_sent">보냄</string>
|
||||
<string name="group_preview_join_as">%s(으)로 참여</string>
|
||||
<string name="group_preview_you_are_invited">그룹에 초대되었어요.</string>
|
||||
<string name="icon_descr_received_msg_status_unread">잃지 않음</string>
|
||||
<string name="icon_descr_sent_msg_status_send_failed">보내기 실패</string>
|
||||
<string name="icon_descr_sent_msg_status_unauthorized_send">비인증 전송</string>
|
||||
<string name="icon_descr_help">도움말</string>
|
||||
<string name="first_platform_without_user_ids">개인을 식별할 수 있는 어떠한 정보(임의의 숫자 포함)도 없는 첫 번째 플랫폼. 단순히 약속이 아니라 프로그램 설계상 완전한 익명성을 제공해요.</string>
|
||||
<string name="icon_descr_call_rejected">거절된 전화</string>
|
||||
<string name="icon_descr_call_pending_sent">대기 중인 전화</string>
|
||||
<string name="icon_descr_call_missed">부재중 전화</string>
|
||||
<string name="icon_descr_file">파일</string>
|
||||
<string name="icon_descr_more_button">더 보기</string>
|
||||
<string name="icon_descr_send_message">메시지 보내기</string>
|
||||
<string name="icon_descr_server_status_error">오류</string>
|
||||
<string name="how_to_use_simplex_chat">사용법</string>
|
||||
<string name="icon_descr_email">이메일</string>
|
||||
<string name="icon_descr_server_status_pending">대기 중</string>
|
||||
<string name="icon_descr_settings">설정</string>
|
||||
<string name="icon_descr_waiting_for_image">이미지 기다리는 중</string>
|
||||
<string name="image_decoding_exception_desc">이미지를 디코딩할 수 없어요. 다른 이미지를 시도하거나 개발자에게 문의해 주세요.</string>
|
||||
<string name="image_descr">이미지</string>
|
||||
<string name="image_saved">갤러리에 사진 저장됨</string>
|
||||
<string name="images_limit_desc">동시에 최대 10개까지만 이미지를 보낼 수 있어요.</string>
|
||||
<string name="images_limit_title">이미지 수가 너무 많아요!</string>
|
||||
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">거절해도 상대에게 알림이 전송되지 않아요.</string>
|
||||
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">직접 만날 수 없다면 <b>영상 통화에서 QR 코드를 보여주거나</b> 링크를 공유해 주세요.</string>
|
||||
<string name="icon_descr_video_call">영상 전화</string>
|
||||
<string name="icon_descr_video_off">영상 끄기</string>
|
||||
<string name="icon_descr_speaker_on">스피커 켜기</string>
|
||||
<string name="icon_descr_video_on">영상 켜기</string>
|
||||
<string name="icon_descr_speaker_off">스피커 끄기</string>
|
||||
<string name="import_database">데이터베이스 가져오기</string>
|
||||
<string name="import_database_confirmation">가져오기</string>
|
||||
<string name="incognito">익명 모드</string>
|
||||
<string name="incognito_info_find">익명 채팅에 사용되는 프로필을 찾으려면 채팅 상단에 있는 연락처 또는 그룹 이름을 탭하세요.</string>
|
||||
<string name="image_will_be_received_when_contact_completes_uploading">대화 상대가 업로드를 완료하면 이미지가 수신될 거예요.</string>
|
||||
<string name="image_descr_profile_image">프로필 이미지</string>
|
||||
<string name="incognito_info_allows">하나의 프로필로 여러 사람과 연락할 필요 없이 무수히 많은 익명 프로필로 연락할 수 있어요.</string>
|
||||
<string name="immune_to_spam_and_abuse">스팸 및 남용에 면역</string>
|
||||
<string name="ignore">무시하기</string>
|
||||
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser"><xliff:g id="appName">SimpleX Chat</xliff:g> 초대 링크를 받으면 브라우저에서 참여할 수 있어요 :</string>
|
||||
<string name="image_descr_link_preview">링크 미리보기 이미지</string>
|
||||
<string name="image_descr_qr_code">QR 코드</string>
|
||||
<string name="icon_descr_simplex_team"><xliff:g id="appName">SimpleX</xliff:g> 팀</string>
|
||||
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">직접 만날 수 없다면 <b>영상 통화에서 QR 코드를 스캔</b>하거나 상대에게 초대 링크를 공유할 수 있어요.</string>
|
||||
<string name="icon_descr_video_snd_complete">동영상 보내짐</string>
|
||||
<string name="icon_descr_video_asked_to_receive">동영상 수신 요청됨</string>
|
||||
<string name="icon_descr_waiting_for_video">동영상 기다리는 중</string>
|
||||
<string name="image_descr_simplex_logo"><xliff:g id="appName">SimpleX</xliff:g> 로고</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">대화 상대가 온라인 상태일 때 이미지가 수신될 거예요. 기다리거나 나중에 확인하세요!</string>
|
||||
<string name="import_database_question">채팅 데이터베이스를 가져올까요\?</string>
|
||||
<string name="server_error">오류</string>
|
||||
<string name="invalid_chat">유효하지 않는 채팅</string>
|
||||
<string name="invalid_data">잘못된 데이터</string>
|
||||
<string name="invalid_message_format">잘못된 메시지 형식</string>
|
||||
<string name="invalid_connection_link">잘못된 연결 링크</string>
|
||||
<string name="notifications">알림</string>
|
||||
<string name="integrity_msg_skipped"><xliff:g id="connection ID" example="1">%1$d</xliff:g> 읽지 않은 메시지</string>
|
||||
<string name="keychain_error">키체인 오류</string>
|
||||
<string name="invite_to_group_button">그룹에 초대하기</string>
|
||||
<string name="info_row_local_name">로컬 네임</string>
|
||||
<string name="join_group_button">참여</string>
|
||||
<string name="join_group_question">그룹에 참여할까요\?</string>
|
||||
<string name="invite_prohibited_description">이 그룹에서는 기본 프로필을 사용하는 중인데 반해, 익명 프로필로 연락하고 있는 대화 상대를 초대하려고 하셨어요.</string>
|
||||
<string name="initial_member_role">초기 역할</string>
|
||||
<string name="info_row_group">그룹</string>
|
||||
<string name="incompatible_database_version">호환되지 않는 데이터베이스 버전</string>
|
||||
<string name="joining_group">그룹에 참여 중</string>
|
||||
<string name="incognito_info_protects">익명 모드는 기본 프로필 이름과 사진과 같은 개인 정보를 보호해줘요. 새 대화 상대마다 새로운 랜덤 프로필이 만들어져요.</string>
|
||||
<string name="is_verified">%s 은(는) 인증되었어요.</string>
|
||||
<string name="italic">기울게</string>
|
||||
<string name="incognito_info_share">익명 프로필 사용 중 초대받은 그룹에 참여하면, 그 그룹에서도 동일한 익명 프로필이 사용되어요.</string>
|
||||
<string name="incognito_random_profile">내 랜덤 프로필</string>
|
||||
<string name="incoming_audio_call">음성 전화 옴</string>
|
||||
<string name="is_not_verified">%s은(는) 인증되지 않았어요.</string>
|
||||
<string name="install_simplex_chat_for_terminal">터미널용 <xliff:g id="appNameFull">SimpleX Chat</xliff:g>를 설치하세요</string>
|
||||
<string name="incoming_video_call">영상 전화 옴</string>
|
||||
<string name="invalid_migration_confirmation">잘못된 마이그레이션 확인</string>
|
||||
<string name="join_group_incognito_button">익명 모드로 참여</string>
|
||||
<string name="invalid_QR_code">잘못된 QR 코드</string>
|
||||
<string name="incorrect_code">잘못된 보안 코드!</string>
|
||||
<string name="invalid_contact_link">잘못된 링크!</string>
|
||||
</resources>
|
||||
@@ -1,50 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="chat_item_ttl_month">1 mėnuo</string>
|
||||
<string name="chat_item_ttl_week">1 savaitė</string>
|
||||
<string name="about_simplex_chat">Apie <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
||||
<string name="chat_item_ttl_day">1 diena</string>
|
||||
<string name="a_plus_b">a + b</string>
|
||||
<string name="about_simplex">Apie SimpleX</string>
|
||||
<string name="smp_servers_add">Pridėti serverį…</string>
|
||||
<string name="v4_3_improved_server_configuration_desc">Pridėti serverius skenuojant QR kodus.</string>
|
||||
<string name="appearance_settings">Išvaizda</string>
|
||||
<string name="app_version_title">Programėlės versija</string>
|
||||
<string name="app_version_name">Programėlės versija: v%s</string>
|
||||
<string name="app_version_code">Programėlės darinys: %s</string>
|
||||
<string name="accept_automatically">Automatiškai</string>
|
||||
<string name="callstatus_calling">skambinama…</string>
|
||||
<string name="callstatus_error">skambučio klaida</string>
|
||||
<string name="call_already_ended">Skambutis jau baigtas!</string>
|
||||
<string name="answer_call">Atsiliepti</string>
|
||||
<string name="icon_descr_call_ended">Skambutis baigtas</string>
|
||||
<string name="settings_section_title_calls">SKAMBUČIAI</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Leisti jūsų adresatams negrįžtamai ištrinti išsiųstas žinutes.</string>
|
||||
<string name="back">Atgal</string>
|
||||
<string name="settings_section_title_icon">PROGRAMĖLĖS PIKTOGRAMA</string>
|
||||
<string name="incognito_random_profile_from_contact_description">Adresatui, iš kurio gavote šią nuorodą, bus išsiųstas atsitiktinis profilis</string>
|
||||
<string name="chat_preferences_always">visada</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Leisti jūsų adresatams siųsti balso žinutes.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Leisti negrįžtamą žinučių ištrynimą tik tuo atveju, jei jūsų adresatas jums tai leidžia.</string>
|
||||
<string name="allow_voice_messages_only_if">Leisti balso žinutes tik tuo atveju, jei jūsų adresatas jas leidžia.</string>
|
||||
<string name="allow_verb">Leisti</string>
|
||||
<string name="allow_voice_messages_question">Leisti balso žinutes\?</string>
|
||||
<string name="bold">pusjuodis</string>
|
||||
<string name="callstatus_ended">skambutis baigtas <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="icon_descr_audio_call">garso skambutis</string>
|
||||
<string name="settings_audio_video_calls">Garso ir vaizdo skambučiai</string>
|
||||
<string name="integrity_msg_bad_hash">bloga žinutės maiša</string>
|
||||
<string name="integrity_msg_bad_id">blogas žinutės ID</string>
|
||||
<string name="incognito_random_profile_description">Jūsų adresatui bus išsiųstas atsitiktinis profilis</string>
|
||||
<string name="allow_disappearing_messages_only_if">Leisti išnykstančias žinutes tik tuo atveju, jei jūsų adresatas jas leidžia.</string>
|
||||
<string name="clear_chat_warning">Visos žinutės bus ištrintos – to neįmanoma bus atšaukti! Žinutės bus ištrintos TIK jums.</string>
|
||||
<string name="allow_to_delete_messages">Leisti negrįžtamai ištrinti išsiųstas žinutes.</string>
|
||||
<string name="allow_to_send_disappearing">Leisti siųsti išnykstančias žinutes.</string>
|
||||
<string name="allow_to_send_voice">Leisti siųsti balso žinutes.</string>
|
||||
<string name="allow_direct_messages">Leisti siųsti tiesiogines žinutes nariams.</string>
|
||||
<string name="v4_6_audio_video_calls">Garso ir vaizdo skambučiai</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Leisti jūsų adresatams siųsti išnykstančias žinutes.</string>
|
||||
<string name="auth_unavailable">Tapatybės nustatymas neprieinamas</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>Turėkite omenyje</b>: jeigu prarasite slaptafrazę, NEBEGALĖSITE jos atkurti ar pakeisti.</string>
|
||||
<string name="cancel_verb">Atsisakyti</string>
|
||||
</resources>
|
||||
@@ -1,438 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="about_simplex_chat">Sobre o <xliff:g id="appNameFull">SimpleX Chat</xliff:g></string>
|
||||
<string name="about_simplex">Sobre o SimpleX</string>
|
||||
<string name="chat_item_ttl_day">1 dia</string>
|
||||
<string name="chat_item_ttl_week">1 semana</string>
|
||||
<string name="chat_item_ttl_month">1 mês</string>
|
||||
<string name="a_plus_b">a + b</string>
|
||||
<string name="alert_title_cant_invite_contacts">Não é possível convidar contatos!</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_changing">mudando endereço…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing_for_member">mudando endereço para %s…</string>
|
||||
<string name="snd_conn_event_switch_queue_phase_changing">"mudando endereço…"</string>
|
||||
<string name="change_role">Mudar regra</string>
|
||||
<string name="change_verb">Mudar</string>
|
||||
<string name="incognito_random_profile_from_contact_description">Um perfil aleatório será enviado para o contato do qual você recebeu este link</string>
|
||||
<string name="both_you_and_your_contacts_can_delete">Você e seu contato podem excluir mensagens enviadas de forma irreversível.</string>
|
||||
<string name="it_can_disabled_via_settings_notifications_still_shown"><b>Pode ser desativado nas configurações</b> – as notificações ainda serão exibidas enquanto o aplicativo estiver em execução.</string>
|
||||
<string name="both_you_and_your_contact_can_send_voice">Você e seu contato podem enviar mensagens de voz.</string>
|
||||
<string name="notifications_mode_off_desc">O aplicativo pode receber notificações apenas quando estiver em execução, nenhum serviço em segundo plano será iniciado</string>
|
||||
<string name="notifications_mode_service">Sempre On</string>
|
||||
<string name="notifications_mode_periodic_desc">Verifica novas mensagens a cada 10 minutos por até 1 minuto</string>
|
||||
<string name="auth_unavailable">Autenticação indisponível</string>
|
||||
<string name="icon_descr_cancel_file_preview">Cancelar visualização do arquivo</string>
|
||||
<string name="icon_descr_asked_to_receive">Pediu para receber a imagem</string>
|
||||
<string name="icon_descr_cancel_live_message">Cancelar mensagem ao vivo</string>
|
||||
<string name="back">Voltar</string>
|
||||
<string name="choose_file">Selecione o arquivo</string>
|
||||
<string name="add_new_contact_to_create_one_time_QR_code"><b>Adicionar novo contato</b>: para criar seu QR code único para seu contato.</string>
|
||||
<string name="scan_QR_code_to_connect_to_contact_who_shows_QR_code"><b>Escanear \u0020QR code</b>: para se conectar ao seu contato que mostra o código QR para você.</string>
|
||||
<string name="accept_contact_button">Aceitar</string>
|
||||
<string name="clear_chat_question">Limpar bate-papo\?</string>
|
||||
<string name="clear_verb">Limpar</string>
|
||||
<string name="clear_chat_button">Limpar bate-papo</string>
|
||||
<string name="clear_chat_menu_action">Limpar</string>
|
||||
<string name="icon_descr_cancel_link_preview">cancelar pré-visualização do link</string>
|
||||
<string name="feature_cancelled_item">cancelado %s</string>
|
||||
<string name="app_version_name">Versão do App: v%s</string>
|
||||
<string name="callstatus_calling">chamando…</string>
|
||||
<string name="callstatus_in_progress">chamada em andamento</string>
|
||||
<string name="accept">Aceitar</string>
|
||||
<string name="call_already_ended">Chamada já encerrada!</string>
|
||||
<string name="icon_descr_call_progress">Chamada em andamento</string>
|
||||
<string name="icon_descr_call_ended">Chamada finalizada</string>
|
||||
<string name="answer_call">Atender ligação</string>
|
||||
<string name="integrity_msg_bad_hash">hash de mensagem ruim</string>
|
||||
<string name="integrity_msg_bad_id">ID de mensagem incorreta</string>
|
||||
<string name="impossible_to_recover_passphrase"><b>Observação</b>: você NÃO poderá recuperar ou alterar a senha se a perder.</string>
|
||||
<string name="cannot_receive_file">Não é possível receber o arquivo</string>
|
||||
<string name="icon_descr_cancel_image_preview">Cancelar visualização da imagem</string>
|
||||
<string name="icon_descr_close_button">Botão Fechar</string>
|
||||
<string name="clear_verification">Limpar verificação</string>
|
||||
<string name="app_version_title">Versão do App</string>
|
||||
<string name="accept_automatically">Automaticamente</string>
|
||||
<string name="bold">negrito</string>
|
||||
<string name="callstatus_error">erro de chamada</string>
|
||||
<string name="settings_audio_video_calls">Chamadas de áudio e vídeo</string>
|
||||
<string name="accept_call_on_lock_screen">Aceitar</string>
|
||||
<string name="call_on_lock_screen">Chamadas na tela de bloqueio:</string>
|
||||
<string name="icon_descr_audio_on">Áudio ligado</string>
|
||||
<string name="chat_database_imported">Banco de dados de bate-papo importado</string>
|
||||
<string name="keychain_is_storing_securely">"Android Keystore é usado para armazenar passphrase com segurança - permite que o serviço de notificação funcione."</string>
|
||||
<string name="keychain_allows_to_receive_ntfs">"O Android Keystore será usado para armazenar passphrase com segurança depois que você reiniciar o aplicativo ou alterar a senha - isso permitirá o recebimento de notificações."</string>
|
||||
<string name="cannot_access_keychain">Não é possível acessar o Keystore para salvar a senha do banco de dados</string>
|
||||
<string name="chat_archive_section">ARQUIVO DE BATE-PAPO</string>
|
||||
<string name="chat_is_stopped_indication">O bate-papo está parado</string>
|
||||
<string name="clear_contacts_selection_button">Limpar</string>
|
||||
<string name="incognito_random_profile_description">Um perfil aleatório será enviado para o seu contato</string>
|
||||
<string name="chat_preferences">Preferências de bate-papo</string>
|
||||
<string name="network_session_mode_user">perfil de bate-papo</string>
|
||||
<string name="accept_requests">Aceitar pedidos</string>
|
||||
<string name="icon_descr_audio_off">Áudio desligado</string>
|
||||
<string name="auto_accept_images">Aceitar imagens automaticamente</string>
|
||||
<string name="chat_database_deleted">Banco de dados de bate-papo excluído</string>
|
||||
<string name="invite_prohibited">Não é possível convidar o contato!</string>
|
||||
<string name="turning_off_service_and_periodic">A otimização da bateria está ativa, desligando o serviço em segundo plano e as solicitações periódicas de novas mensagens. Você pode reativá-los através das configurações.</string>
|
||||
<string name="database_initialization_error_title">Não é possível inicializar o banco de dados</string>
|
||||
<string name="attach">Anexar</string>
|
||||
<string name="cancel_verb">Cancelar</string>
|
||||
<string name="chat_console">Console de bate-papo</string>
|
||||
<string name="smp_servers_check_address">Verifique o endereço do servidor e tente novamente.</string>
|
||||
<string name="network_session_mode_user_description">Uma conexão TCP separada (e credencial SOCKS) será usada <b>para cada perfil de bate-papo que você tiver no aplicativo</b>.</string>
|
||||
<string name="onboarding_notifications_mode_off_desc"><b>Melhor para bateria</b>. Você receberá notificações apenas quando o aplicativo estiver em execução, o serviço em segundo plano NÃO será usado.</string>
|
||||
<string name="onboarding_notifications_mode_service_desc"><b>Consome mais bateria</b>! O serviço em segundo plano está sempre em execução - as notificações serão exibidas assim que as mensagens estiverem disponíveis.</string>
|
||||
<string name="settings_section_title_chats">CONVERSAS</string>
|
||||
<string name="settings_section_title_icon">ÍCONE DO APP</string>
|
||||
<string name="chat_database_section">BANCO DE DADOS DE BATE-PAPO</string>
|
||||
<string name="chat_is_running">O bate-papo está em execução</string>
|
||||
<string name="chat_is_stopped">O bate-papo está parado</string>
|
||||
<string name="change_database_passphrase_question">Alterar passphrase do banco de dados\?</string>
|
||||
<string name="chat_archive_header">Arquivo de bate-papo</string>
|
||||
<string name="rcv_conn_event_switch_queue_phase_completed">endereço alterado para você</string>
|
||||
<string name="both_you_and_your_contact_can_send_disappearing">Você e seu contato podem enviar mensagens que desaparecem.</string>
|
||||
<string name="full_backup">Backup de dados do aplicativo</string>
|
||||
<string name="settings_section_title_calls">CHAMADAS</string>
|
||||
<string name="v4_2_auto_accept_contact_requests">Aceitar solicitações de contato automaticamente</string>
|
||||
<string name="appearance_settings">Aparência</string>
|
||||
<string name="notifications_mode_service_desc">O serviço em segundo plano está sempre em execução - as notificações serão exibidas assim que as mensagens estiverem disponíveis.</string>
|
||||
<string name="network_session_mode_entity_description">Uma conexão TCP separada (e credencial SOCKS) será usada <b>para cada contato e membro do grupo</b>. <b>Observação</b>: se você tiver muitas conexões, o consumo de bateria e tráfego pode ser substancialmente maior e algumas conexões podem falhar.</string>
|
||||
<string name="onboarding_notifications_mode_periodic_desc"><b>Bom para bateria</b>. O serviço em segundo plano verifica novas mensagens a cada 10 minutos. Você pode perder chamadas e mensagens urgentes.</string>
|
||||
<string name="callstatus_ended">chamada finalizada <xliff:g id="duration" example="01:15">%1$s</xliff:g></string>
|
||||
<string name="chat_with_developers">Bate-papo com os desenvolvedores</string>
|
||||
<string name="create_group_link">Criar link de grupo</string>
|
||||
<string name="button_create_group_link">Criar link</string>
|
||||
<string name="create_secret_group_title">Criar grupo secreto</string>
|
||||
<string name="theme_dark">Escuro</string>
|
||||
<string name="connect_via_invitation_link">Conectar via link de convite\?</string>
|
||||
<string name="connect_via_contact_link">Conectar via link de contato\?</string>
|
||||
<string name="smp_server_test_create_queue">Criar fila</string>
|
||||
<string name="notification_preview_mode_contact">Nome de contato</string>
|
||||
<string name="notification_preview_somebody">Contato oculto:</string>
|
||||
<string name="copy_verb">Copiar</string>
|
||||
<string name="allow_verb">Permitir</string>
|
||||
<string name="allow_to_send_disappearing">Permitir enviar mensagens que desaparecem.</string>
|
||||
<string name="allow_direct_messages">Permitir o envio de mensagens diretas aos membros.</string>
|
||||
<string name="connect_via_link_or_qr">Conectar via link/ QR Code</string>
|
||||
<string name="clear_chat_warning">"Todas as mensagens serão excluídas - isso não pode ser desfeito! As mensagens serão excluídas APENAS para você."</string>
|
||||
<string name="smp_servers_preset_add">Adicionar servidores predefinidos</string>
|
||||
<string name="smp_servers_add">Adicionar servidor…</string>
|
||||
<string name="create_your_profile">Crie seu perfil</string>
|
||||
<string name="icon_descr_context">Ícone de contexto</string>
|
||||
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contato e todas as mensagens serão excluídas - isso não pode ser desfeito!</string>
|
||||
<string name="copied">Copiado para a área de transferência</string>
|
||||
<string name="accept_connection_request__question">Aceitar solicitação de conexão\?</string>
|
||||
<string name="network_settings">Configurações de rede avançadas</string>
|
||||
<string name="contact_requests">Solicitações de contato</string>
|
||||
<string name="create_address">Criar endereço</string>
|
||||
<string name="all_your_contacts_will_remain_connected">Todos os seus contatos permanecerão conectados.</string>
|
||||
<string name="callstatus_accepted">chamada aceita</string>
|
||||
<string name="status_contact_has_e2e_encryption">Contato tem criptografia e2e</string>
|
||||
<string name="status_contact_has_no_e2e_encryption">contato não tem criptografia e2e</string>
|
||||
<string name="contact_preferences">Preferências de contato</string>
|
||||
<string name="allow_to_delete_messages">Permite excluir irreversivelmente as mensagens enviadas.</string>
|
||||
<string name="chat_preferences_always">sempre</string>
|
||||
<string name="v4_3_improved_server_configuration_desc">Adicione servidores digitalizando QR code.</string>
|
||||
<string name="allow_to_send_voice">Permitir enviar mensagens de voz.</string>
|
||||
<string name="create_group">Criar grupo secreto</string>
|
||||
<string name="always_use_relay">Conectar via relay</string>
|
||||
<string name="users_add">Adicionar perfil</string>
|
||||
<string name="connect_via_link">Conectar via link</string>
|
||||
<string name="create_profile">Criar perfil</string>
|
||||
<string name="database_encrypted">Banco de dados criptografado!</string>
|
||||
<string name="group_member_status_creator">criador</string>
|
||||
<string name="users_delete_all_chats_deleted">Todos os bate-papos e mensagens serão excluídos - isso não pode ser desfeito!</string>
|
||||
<string name="accept_feature">Aceitar</string>
|
||||
<string name="allow_disappearing_messages_only_if">Permitir mensagens que desaparecem apenas se o seu contato permitir.</string>
|
||||
<string name="allow_irreversible_message_deletion_only_if">Permita a exclusão irreversível da mensagem somente se o seu contato permitir.</string>
|
||||
<string name="allow_your_contacts_to_send_disappearing_messages">Permitir que seus contatos enviem mensagens que desaparecem.</string>
|
||||
<string name="allow_voice_messages_only_if">Permitir mensagens de voz somente se o seu contato permitir.</string>
|
||||
<string name="allow_your_contacts_to_send_voice_messages">Permitir que seus contatos enviem mensagens de voz.</string>
|
||||
<string name="group_member_role_admin">admin</string>
|
||||
<string name="all_group_members_will_remain_connected">Todos os membros do grupo permanecerão conectados.</string>
|
||||
<string name="contacts_can_mark_messages_for_deletion">"Contatos podem marcar mensagens para exclusão; você será capaz de visualizá-los."</string>
|
||||
<string name="connect_via_group_link">Conectar via link do grupo\?</string>
|
||||
<string name="contact_already_exists">Contato já existe</string>
|
||||
<string name="icon_descr_contact_checked">Contato verificado</string>
|
||||
<string name="alert_title_contact_connection_pending">Contato ainda não está conectado!</string>
|
||||
<string name="contribute">Contribuir</string>
|
||||
<string name="create_profile_button">Criar</string>
|
||||
<string name="network_enable_socks_info">"Acessar os servidores via proxy SOCKS na porta 9050\? O proxy deve ser iniciado antes de habilitar esta opção."</string>
|
||||
<string name="allow_your_contacts_irreversibly_delete">Permitir que seus contatos excluam de forma irreversível as mensagens enviadas.</string>
|
||||
<string name="smp_servers_add_to_another_device">Adicionar a outro dispositivo</string>
|
||||
<string name="v4_2_group_links_desc">Os administradores podem criar os links para ingressar em grupos.</string>
|
||||
<string name="allow_voice_messages_question">Permitir mensagens de voz\?</string>
|
||||
<string name="button_delete_group">Excluir grupo</string>
|
||||
<string name="info_row_connection">Conexão</string>
|
||||
<string name="users_delete_question">Excluir perfil de bate-papo\?</string>
|
||||
<string name="full_deletion">Excluir para todos</string>
|
||||
<string name="connect_via_link_verb">Conectar</string>
|
||||
<string name="server_connected">conectado</string>
|
||||
<string name="server_connecting">conectando</string>
|
||||
<string name="deleted_description">excluído</string>
|
||||
<string name="smp_server_test_connect">Conectar</string>
|
||||
<string name="delete_group_menu_action">Excluir</string>
|
||||
<string name="connect_button">Conectar</string>
|
||||
<string name="callstatus_connecting">conectando chamada…</string>
|
||||
<string name="delete_chat_profile_question">Excluir perfil de bate-papo\?</string>
|
||||
<string name="delete_files_and_media_for_all_users">Excluir arquivos de todos os perfis de bate-papo</string>
|
||||
<string name="display_name_connecting">conectando…</string>
|
||||
<string name="connection_error">Erro de conexão</string>
|
||||
<string name="button_delete_contact">Excluir contato</string>
|
||||
<string name="configure_ICE_servers">Configurar servidores ICE</string>
|
||||
<string name="delete_address__question">Excluir endereço\?</string>
|
||||
<string name="decentralized">Descentralizado</string>
|
||||
<string name="delete_database">Excluir banco de dados</string>
|
||||
<string name="set_password_to_export_desc">"O banco de dados é criptografado usando um passphrase aleatório. Por favor, altere-o antes de exportar."</string>
|
||||
<string name="confirm_new_passphrase">Confirmar nova passphrase…</string>
|
||||
<string name="current_passphrase">Passphrase atual…</string>
|
||||
<string name="database_passphrase_is_required">Passphrase do banco de dados é necessária para abrir o chat.</string>
|
||||
<string name="delete_archive">Excluir arquivo</string>
|
||||
<string name="delete_chat_archive_question">Excluir arquivo de bate-papo\?</string>
|
||||
<string name="rcv_group_event_changed_member_role">regra alterada de %s para %s</string>
|
||||
<string name="rcv_group_event_member_connected">conectado</string>
|
||||
<string name="delete_link">Excluir link</string>
|
||||
<string name="delete_link_question">Excluir link\?</string>
|
||||
<string name="chat_preferences_default">padrão (%s)</string>
|
||||
<string name="connection_request_sent">Solicitação de conexão enviada!</string>
|
||||
<string name="group_member_status_connecting">conectando</string>
|
||||
<string name="contact_connection_pending">conectando…</string>
|
||||
<string name="for_me_only">Excluir para mim</string>
|
||||
<string name="group_connection_pending">conectando…</string>
|
||||
<string name="delete_contact_question">Excluir contato\?</string>
|
||||
<string name="confirm_verb">confirmar</string>
|
||||
<string name="database_passphrase_and_export">Passphrase e exportação do banco de dados</string>
|
||||
<string name="icon_descr_call_connecting">Conectando chamada</string>
|
||||
<string name="delete_messages">Excluir mensagens</string>
|
||||
<string name="database_passphrase_will_be_updated">Passphrase de criptografia do banco de dados será atualizada.</string>
|
||||
<string name="database_error">Erro de banco de dados</string>
|
||||
<string name="passphrase_is_different">Passphrase do banco de dados é diferente da salva no Keystore.</string>
|
||||
<string name="group_member_status_complete">completo</string>
|
||||
<string name="info_row_database_id">ID do banco de dados</string>
|
||||
<string name="colored">colorido</string>
|
||||
<string name="callstate_connected">conectado</string>
|
||||
<string name="notification_contact_connected">Conectado</string>
|
||||
<string name="icon_descr_server_status_connected">Conectado</string>
|
||||
<string name="audio_call_no_encryption">chamada de áudio (não criptografada em e2e)</string>
|
||||
<string name="change_member_role_question">Alterar a regra do grupo\?</string>
|
||||
<string name="icon_descr_audio_call">chamada de áudio</string>
|
||||
<string name="rcv_group_event_changed_your_role">mudou sua regra para %s</string>
|
||||
<string name="v4_4_verify_connection_security_desc">Compare os códigos de segurança com seus contatos.</string>
|
||||
<string name="auth_confirm_credential">Confirme sua credencial</string>
|
||||
<string name="callstate_connecting">conectando…</string>
|
||||
<string name="group_member_status_announced">conectando (anunciado)</string>
|
||||
<string name="network_session_mode_entity">Conexão</string>
|
||||
<string name="connection_error_auth">Erro de conexão (AUTH)</string>
|
||||
<string name="display_name_connection_established">conexão estabelecida</string>
|
||||
<string name="connection_local_display_name">conexão <xliff:g id="connection ID" example="1">%1$d</xliff:g></string>
|
||||
<string name="archive_created_on_ts">Criado em <xliff:g id="archive_ts">%1$s</xliff:g></string>
|
||||
<string name="maximum_supported_file_size">Atualmente, o tamanho máximo de arquivo suportado é <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
|
||||
<string name="delete_verb">Excluir</string>
|
||||
<string name="database_encryption_will_be_updated">Passphase de criptografia do banco de dados será atualizada e armazenada no Keystore.</string>
|
||||
<string name="delete_address">Excluir endereço</string>
|
||||
<string name="encrypted_with_random_passphrase">"O banco de dados é criptografado usando um passphrase aleatório, você pode alterá-la."</string>
|
||||
<string name="database_passphrase">Passphrase do banco de dados</string>
|
||||
<string name="database_will_be_encrypted_and_passphrase_stored">O banco de dados será criptografado e o passphrase armazenado no Keystore.</string>
|
||||
<string name="database_will_be_encrypted">O banco de dados será criptografado.</string>
|
||||
<string name="ttl_day">%d dia</string>
|
||||
<string name="image_decoding_exception_title">Erro de decodificação</string>
|
||||
<string name="delete_contact_menu_action">Excluir</string>
|
||||
<string name="ttl_days">%d dias</string>
|
||||
<string name="delete_files_and_media_all">Excluir todos os arquivos</string>
|
||||
<string name="delete_message__question">Excluir mensagem\?</string>
|
||||
<string name="delete_after">Excluir depois</string>
|
||||
<string name="users_delete_profile_for">Excluir perfil de bate-papo para</string>
|
||||
<string name="rcv_group_event_group_deleted">grupo excluído</string>
|
||||
<string name="delete_group_question">Excluir grupo\?</string>
|
||||
<string name="delete_image">Excluir imagem</string>
|
||||
<string name="delete_files_and_media_question">Excluir arquivos e mídia\?</string>
|
||||
<string name="group_member_status_connected">conectado</string>
|
||||
<string name="group_member_status_accepted">conectando (aceito)</string>
|
||||
<string name="ttl_d">%dd</string>
|
||||
<string name="v4_5_transport_isolation_descr">Por perfil de bate-papo (padrão) ou por conexão (BETA).</string>
|
||||
<string name="accept_contact_incognito_button">Aceitar anônimo</string>
|
||||
<string name="delete_messages_after">Excluir mensagens após</string>
|
||||
<string name="desktop_scan_QR_code_from_app_via_scan_QR_code">💻 desktop: Scaneie o QR code exibido no aplicativo, via <b>Scan QR code</b></string>
|
||||
<string name="delete_pending_connection__question">Excluir conexão pendente\?</string>
|
||||
<string name="simplex_link_mode_description">Descrição</string>
|
||||
<string name="smp_servers_delete_server">Excluir servidor</string>
|
||||
<string name="group_member_status_intro_invitation">conectando (introduction invitation)</string>
|
||||
<string name="connection_timeout">Tempo de conexão esgotado</string>
|
||||
<string name="delete_member_message__question">Excluir mensagem do membro\?</string>
|
||||
<string name="smp_server_test_delete_queue">Excluir fila</string>
|
||||
<string name="settings_section_title_device">DISPOSITIVO</string>
|
||||
<string name="settings_developer_tools">Ferramentas de desenvolvimento</string>
|
||||
<string name="group_member_status_introduced">conectando (introduced)</string>
|
||||
<string name="color_primary">Realçar</string>
|
||||
<string name="error_removing_member">Erro ao remover membro</string>
|
||||
<string name="error_changing_role">Erro ao alterar regra</string>
|
||||
<string name="conn_level_desc_direct">direto</string>
|
||||
<string name="server_error">erro</string>
|
||||
<string name="failed_to_parse_chat_title">Falha ao carregar o bate-papo</string>
|
||||
<string name="error_setting_network_config">Erro ao atualizar a configuração de rede</string>
|
||||
<string name="error_sending_message">Erro ao enviar mensagem</string>
|
||||
<string name="error_adding_members">Erro ao adicionar membro(s)</string>
|
||||
<string name="smp_server_test_disconnect">Desconectar</string>
|
||||
<string name="error_deleting_user">Erro ao excluir perfil do usuário</string>
|
||||
<string name="ttl_s">%ds</string>
|
||||
<string name="ttl_months">%d meses</string>
|
||||
<string name="ttl_weeks">%d semanas</string>
|
||||
<string name="encrypt_database_question">Criptografar banco de dados\?</string>
|
||||
<string name="error_receiving_file">Erro ao receber arquivo</string>
|
||||
<string name="error_creating_address">Erro ao criar endereço</string>
|
||||
<string name="display_name__field">Nome de exibição:</string>
|
||||
<string name="error_starting_chat">Erro ao iniciar o bate-papo</string>
|
||||
<string name="error_deleting_database">Erro ao excluir banco de dados de bate-papo</string>
|
||||
<string name="encrypt_database">Criptografar</string>
|
||||
<string name="network_option_enable_tcp_keep_alive">Ativar TCP keep-alive</string>
|
||||
<string name="failed_to_create_user_title">Erro ao criar perfil!</string>
|
||||
<string name="error_joining_group">Erro ao ingressar no grupo</string>
|
||||
<string name="failed_to_create_user_duplicate_title">Nome de exibição duplicado!</string>
|
||||
<string name="error_deleting_contact">Erro ao excluir contato</string>
|
||||
<string name="error_changing_address">Erro ao alterar endereço</string>
|
||||
<string name="error_deleting_pending_contact_connection">Erro ao excluir conexão de contato pendente</string>
|
||||
<string name="edit_verb">Editar</string>
|
||||
<string name="enable_automatic_deletion_question">Ativar exclusão automática de mensagens\?</string>
|
||||
<string name="ttl_sec">%d sec</string>
|
||||
<string name="error_saving_smp_servers">Erro ao salvar servidores SMP</string>
|
||||
<string name="error_accepting_contact_request">Erro ao aceitar solicitação de contato</string>
|
||||
<string name="error_deleting_contact_request">Erro ao excluir solicitação de contato</string>
|
||||
<string name="failed_to_active_user_title">Erro ao trocar de perfil!</string>
|
||||
<string name="auth_disable_simplex_lock">Desativar Bloqueio SimpleX</string>
|
||||
<string name="auth_enable_simplex_lock">Ativar Bloqueio SimpleX</string>
|
||||
<string name="icon_descr_edited">editado</string>
|
||||
<string name="icon_descr_server_status_error">Erro</string>
|
||||
<string name="icon_descr_email">Email</string>
|
||||
<string name="error_saving_ICE_servers">Erro ao salvar servidores ICE</string>
|
||||
<string name="exit_without_saving">Sair sem salvar</string>
|
||||
<string name="display_name">Nome de exibição</string>
|
||||
<string name="encrypted_video_call">chamada de vídeo criptografada e2e</string>
|
||||
<string name="integrity_msg_duplicate">mensagem duplicada</string>
|
||||
<string name="status_e2e_encrypted">criptografado e2e</string>
|
||||
<string name="export_database">Exportar banco de dados</string>
|
||||
<string name="total_files_count_and_size">%d arquivo(s) com tamanho total de %s</string>
|
||||
<string name="error_exporting_chat_database">Erro ao exportar banco de dados de bate-papo</string>
|
||||
<string name="error_importing_database">Erro ao importar banco de dados de bate-papo</string>
|
||||
<string name="error_stopping_chat">Erro ao interromper o bate-papo</string>
|
||||
<string name="error_changing_message_deletion">Erro ao alterar configuração</string>
|
||||
<string name="error_encrypting_database">Erro ao criptografar o banco de dados</string>
|
||||
<string name="encrypted_database">Banco de dados criptografado</string>
|
||||
<string name="enter_correct_passphrase">Digite o passphrase correto.</string>
|
||||
<string name="enter_passphrase">Digite o passphrase…</string>
|
||||
<string name="error_with_info">Erro: %s</string>
|
||||
<string name="button_edit_group_profile">Editar perfil do grupo</string>
|
||||
<string name="icon_descr_expand_role">Expandir seleção de regra</string>
|
||||
<string name="error_saving_group_profile">Erro ao salvar o perfil do grupo</string>
|
||||
<string name="direct_messages">Mensagens diretas</string>
|
||||
<string name="feature_enabled">habilitado</string>
|
||||
<string name="feature_enabled_for_contact">habilitado para contato</string>
|
||||
<string name="feature_enabled_for_you">ativado para você</string>
|
||||
<string name="ttl_m">%dm</string>
|
||||
<string name="ttl_min">%d min</string>
|
||||
<string name="ttl_month">%d mês</string>
|
||||
<string name="ttl_week">%d semana</string>
|
||||
<string name="ttl_hour">%d hora</string>
|
||||
<string name="auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled">A autenticação do dispositivo não está habilitada. Você pode ativar o Bloqueio SimpleX em Configurações, depois de ativar a autenticação do dispositivo.</string>
|
||||
<string name="no_call_on_lock_screen">Desativar</string>
|
||||
<string name="icon_descr_server_status_disconnected">Desconectado</string>
|
||||
<string name="disappearing_messages_are_prohibited">Mensagens que desaparecem(temporárias) são proibidas neste grupo.</string>
|
||||
<string name="error_saving_file">Erro ao salvar arquivo</string>
|
||||
<string name="display_name_cannot_contain_whitespace">O nome de exibição não pode conter espaços em branco.</string>
|
||||
<string name="encrypted_audio_call">chamada de áudio criptografada e2e</string>
|
||||
<string name="edit_image">Editar imagem</string>
|
||||
<string name="smp_servers_enter_manually">Insira o servidor manualmente</string>
|
||||
<string name="error_deleting_group">Erro ao excluir grupo</string>
|
||||
<string name="settings_experimental_features">Funcionalidades experimentais</string>
|
||||
<string name="error_creating_link_for_group">Erro ao criar o link de grupo</string>
|
||||
<string name="error_deleting_link_for_group">Erro ao excluir o link de grupo</string>
|
||||
<string name="direct_messages_are_prohibited_in_chat">Mensagens diretas entre membros são proibidas neste grupo.</string>
|
||||
<string name="ttl_h">%dh</string>
|
||||
<string name="ttl_hours">%d horas</string>
|
||||
<string name="description_via_contact_address_link_incognito">anônimo via link de endereço de contato</string>
|
||||
<string name="description_via_one_time_link_incognito">anônimo via link único</string>
|
||||
<string name="hide_verb">Esconder</string>
|
||||
<string name="from_gallery_button">Da Galeria</string>
|
||||
<string name="group_members_can_send_disappearing">Os membros do grupo podem enviar mensagens que desaparecem.</string>
|
||||
<string name="icon_descr_file">Arquivo</string>
|
||||
<string name="full_name__field">Nome completo:</string>
|
||||
<string name="incoming_audio_call">Chamada de áudio recebida</string>
|
||||
<string name="v4_4_disappearing_messages">Mensagens que desaparecem</string>
|
||||
<string name="v4_3_improved_privacy_and_security_desc">"Ocultar aplicativo nos aplicativos recentes."</string>
|
||||
<string name="icon_descr_image_snd_complete">Imagem enviada</string>
|
||||
<string name="group_link">Link do grupo</string>
|
||||
<string name="import_database">Importar banco de dados</string>
|
||||
<string name="alert_message_group_invitation_expired">O convite de grupo não é mais válido, foi removido pelo remetente.</string>
|
||||
<string name="icon_descr_group_inactive">Grupo inativo</string>
|
||||
<string name="alert_title_no_group">Grupo não encontrado!</string>
|
||||
<string name="delete_group_for_self_cannot_undo_warning">O grupo será excluído para você - isso não pode ser desfeito!</string>
|
||||
<string name="info_row_group">Grupo</string>
|
||||
<string name="conn_level_desc_indirect">indireto (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
|
||||
<string name="incognito">Anônimo</string>
|
||||
<string name="timed_messages">Mensagens que desaparecem</string>
|
||||
<string name="group_preferences">Preferências de grupo</string>
|
||||
<string name="disappearing_prohibited_in_this_chat">Mensagens que desaparecem são proibidas nesse bate-papo.</string>
|
||||
<string name="group_members_can_send_dms">Os membros do grupo podem enviar mensagens diretas.</string>
|
||||
<string name="ttl_mth">%dmês</string>
|
||||
<string name="simplex_link_mode_full">Link completo</string>
|
||||
<string name="hide_notification">Esconder</string>
|
||||
<string name="auth_device_authentication_is_disabled_turning_off">A autenticação do dispositivo está desativada. Desativando o SimpleX Lock.</string>
|
||||
<string name="for_everybody">Para todos</string>
|
||||
<string name="notification_preview_mode_hidden">Escondido</string>
|
||||
<string name="create_one_time_link">Gerar um link de convite único.</string>
|
||||
<string name="how_to_use_your_servers">Como usar seus servidores</string>
|
||||
<string name="import_database_confirmation">Importar</string>
|
||||
<string name="import_database_question">Importar banco de dados de bate-papo\?</string>
|
||||
<string name="group_display_name_field">Nome de exibição do grupo:</string>
|
||||
<string name="group_full_name_field">Nome completo do grupo:</string>
|
||||
<string name="v4_2_group_links">Links de grupo</string>
|
||||
<string name="v4_3_improved_privacy_and_security">Privacidade e segurança aprimoradas</string>
|
||||
<string name="failed_to_parse_chats_title">Falha ao carregar bate-papos</string>
|
||||
<string name="file_with_path">Arquivo: %s</string>
|
||||
<string name="file_saved">Arquivo salvo</string>
|
||||
<string name="group_members_can_send_voice">Os membros do grupo podem enviar mensagens de voz.</string>
|
||||
<string name="delete_group_for_all_members_cannot_undo_warning">O grupo será excluído para todos os membros - isso não pode ser desfeito!</string>
|
||||
<string name="settings_section_title_help">AJUDA</string>
|
||||
<string name="notification_display_mode_hidden_desc">Ocultar contato e mensagem</string>
|
||||
<string name="how_to_use_simplex_chat">Como usar</string>
|
||||
<string name="how_to_use_markdown">Como usar markdown</string>
|
||||
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Se você não puder se encontrar pessoalmente, <b>mostre o QR code na videochamada</b> ou compartilhe o link.</string>
|
||||
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Se você não puder encontrar pessoalmente, você pode <b>escanear o QR code na videochamada</b> ou seu contato pode compartilhar um link de convite.</string>
|
||||
<string name="network_disable_socks_info">Se você confirmar, os servidores de mensagens poderão ver seu endereço IP e seu provedor - e quais servidores você está se conectando.</string>
|
||||
<string name="image_descr">Imagem</string>
|
||||
<string name="if_you_received_simplex_invitation_link_you_can_open_in_browser">Se você recebeu o link de convite <xliff:g id="appName">SimpleX Chat</xliff:g>, você pode abri-lo em seu navegador:</string>
|
||||
<string name="image_saved">Imagem salva na galeria</string>
|
||||
<string name="group_unsupported_incognito_main_profile_sent">O modo de navegação anônima não é suportado aqui - seu perfil principal será enviado aos membros do grupo</string>
|
||||
<string name="description_via_group_link_incognito">anônimo via link do grupo</string>
|
||||
<string name="incoming_video_call">Chamada de vídeo recebida</string>
|
||||
<string name="turn_off_battery_optimization">Para usá-lo, por favor <b>desative a otimização da bateria</b> para <xliff:g id="appName">SimpleX</xliff:g> na próxima caixa de diálogo. Caso contrário, as notificações serão desativadas.</string>
|
||||
<string name="share_one_time_link">Gerar um link de convite único</string>
|
||||
<string name="file_not_found">Arquivo não encontrado</string>
|
||||
<string name="if_you_choose_to_reject_the_sender_will_not_be_notified">Se você optar por rejeitar o remetente NÃO será notificado.</string>
|
||||
<string name="incorrect_code">Código de segurança incorreto!</string>
|
||||
<string name="install_simplex_chat_for_terminal">Instale o <xliff:g id="appNameFull">SimpleX Chat</xliff:g> para o terminal</string>
|
||||
<string name="full_name_optional__prompt">Nome Completo (opcional)</string>
|
||||
<string name="how_it_works">Como funciona</string>
|
||||
<string name="immune_to_spam_and_abuse">Imune a spam e abuso</string>
|
||||
<string name="icon_descr_flip_camera">Vire a câmera</string>
|
||||
<string name="icon_descr_hang_up">Desligar</string>
|
||||
<string name="settings_section_title_incognito">Modo anônimo</string>
|
||||
<string name="initial_member_role">Regra inicial</string>
|
||||
<string name="snd_group_event_group_profile_updated">perfil do grupo atualizado</string>
|
||||
<string name="group_member_status_group_deleted">Grupo excluído</string>
|
||||
<string name="incognito_info_protects">O modo de navegação anônima protege a privacidade do nome e da imagem do seu perfil principal — para cada novo contato, um novo perfil aleatório é criado.</string>
|
||||
<string name="group_members_can_delete">Os membros do grupo podem excluir mensagens enviadas de forma irreversível.</string>
|
||||
<string name="ttl_w">%dsemana</string>
|
||||
<string name="v4_3_improved_server_configuration">Configuração de servidor aprimorada</string>
|
||||
<string name="v4_4_french_interface">Interface francesa</string>
|
||||
<string name="callstate_ended">terminou</string>
|
||||
<string name="allow_accepting_calls_from_lock_screen">Ative as chamadas pela tela de bloqueio nas Configurações.</string>
|
||||
<string name="files_and_media_section">Arquivos & mídia</string>
|
||||
<string name="error_updating_link_for_group">Erro ao atualizar o link do grupo</string>
|
||||
<string name="group_invitation_expired">O convite do grupo expirou</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">O arquivo será recebido quando seu contato estiver online, aguarde ou verifique mais tarde!</string>
|
||||
<string name="group_profile_is_stored_on_members_devices">O perfil do grupo é armazenado nos dispositivos dos membros, não nos servidores.</string>
|
||||
<string name="icon_descr_help">ajuda</string>
|
||||
<string name="how_simplex_works">Como <xliff:g id="appName">SimpleX</xliff:g> funciona</string>
|
||||
<string name="enter_one_ICE_server_per_line">Servidores ICE (um por linha)</string>
|
||||
<string name="ignore">Ignorar</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">A imagem será recebida quando seu contato estiver online, aguarde ou verifique mais tarde!</string>
|
||||
</resources>
|
||||
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
@@ -1,50 +0,0 @@
|
||||
buildscript {
|
||||
Properties localProperties = new Properties()
|
||||
if (rootProject.file('local.properties').canRead()) {
|
||||
localProperties.load(rootProject.file("local.properties").newDataInputStream())
|
||||
}
|
||||
|
||||
ext {
|
||||
compose_version = localProperties['compose_version'] ?: '1.2.0-beta02'
|
||||
kotlin_version = localProperties['kotlin_version'] ?: '1.6.21'
|
||||
gradle_plugin_version = localProperties['gradle_plugin_version'] ?: '7.2.0'
|
||||
abi_filter = localProperties['abi_filter'] ?: 'arm64-v8a'
|
||||
|
||||
// Name that will be shown for debug build. By default it is from strings
|
||||
app_name = localProperties['app_name'] ?: "@string/app_name"
|
||||
// Whether the app is debuggable or not. Specify `false` if you want good performance in debug builds
|
||||
enable_debuggable = localProperties['debuggable'] ?: true
|
||||
// Ending part of package name.
|
||||
// Provide, for example, `application_id_suffix=.debug` in local.properties
|
||||
// to allow debug & release versions to coexist
|
||||
application_id_suffix = localProperties['application_id_suffix'] ?: ''
|
||||
|
||||
// Compression level for debug AND release apk. 0 = disable compression. Max is 9
|
||||
compression_level = localProperties['compression_level'] ?: '0'
|
||||
|
||||
// NOTE: If you need a different version of something, provide it in `local.properties`
|
||||
// like so: compose_version=123, or gradle_plugin_version=1.2.3, etc
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath "com.android.tools.build:gradle:$gradle_plugin_version"
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id 'com.android.application' version "$gradle_plugin_version" apply false
|
||||
id 'com.android.library' version "$gradle_plugin_version" apply false
|
||||
id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "SimpleX"
|
||||
include ':app'
|
||||
@@ -14,9 +14,28 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||
application.registerForRemoteNotifications()
|
||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
||||
return true
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private func trackKeyboard() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@objc func keyboardWillShow(_ notification: Notification) {
|
||||
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
||||
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@objc func keyboardWillHide(_ notification: Notification) {
|
||||
ChatModel.shared.keyboardHeight = 0
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
|
||||
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
|
||||
@@ -42,7 +61,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
m.notificationMode != .off {
|
||||
if let verification = ntfData["verification"] as? String,
|
||||
let nonce = ntfData["nonce"] as? String {
|
||||
if let token = ChatModel.shared.deviceToken {
|
||||
if let token = m.deviceToken {
|
||||
logger.debug("AppDelegate: didReceiveRemoteNotification: verification, confirming \(verification)")
|
||||
Task {
|
||||
do {
|
||||
@@ -62,7 +81,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
}
|
||||
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
|
||||
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
|
||||
if appStateGroupDefault.get().inactive {
|
||||
if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic {
|
||||
receiveMessages(completionHandler)
|
||||
} else {
|
||||
completionHandler(.noData)
|
||||
@@ -76,7 +95,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
logger.debug("AppDelegate: applicationWillTerminate")
|
||||
logger.debug("DEBUGGING: AppDelegate: applicationWillTerminate")
|
||||
ChatModel.shared.filesToDelete.forEach {
|
||||
removeFile($0)
|
||||
}
|
||||
|
||||
@@ -18,12 +18,16 @@ 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
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
|
||||
@State private var showSettings = false
|
||||
@State private var showWhatsNew = false
|
||||
@State private var showChooseLAMode = false
|
||||
@State private var showSetPasscode = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -31,6 +35,20 @@ struct ContentView: View {
|
||||
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||
callView(call)
|
||||
}
|
||||
if !showSettings, let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
} else if showSetPasscode {
|
||||
SetAppPasscodeView {
|
||||
prefPerformLA = true
|
||||
showSetPasscode = false
|
||||
privacyLocalAuthModeDefault.set(.passcode)
|
||||
alertManager.showAlert(laTurnedOnAlert())
|
||||
} cancel: {
|
||||
prefPerformLA = false
|
||||
showSetPasscode = false
|
||||
alertManager.showAlert(laPasscodeNotSetAlert())
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if prefPerformLA { requestNtfAuthorization() }
|
||||
@@ -40,11 +58,20 @@ struct ContentView: View {
|
||||
initAuthenticate()
|
||||
}
|
||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(showSettings: $showSettings)
|
||||
}
|
||||
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
|
||||
Button("System authentication") { initialEnableLA() }
|
||||
Button("Passcode entry") { showSetPasscode = true }
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
@@ -80,9 +107,17 @@ 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().privacySensitive(protectScreen)
|
||||
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
||||
.onAppear {
|
||||
if !prefPerformLA { requestNtfAuthorization() }
|
||||
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||
@@ -101,6 +136,9 @@ struct ContentView: View {
|
||||
.sheet(isPresented: $showWhatsNew) {
|
||||
WhatsNewView()
|
||||
}
|
||||
if chatModel.setDeliveryReceipts {
|
||||
SetDeliveryReceiptsView()
|
||||
}
|
||||
IncomingCallView()
|
||||
}
|
||||
.onContinueUserActivity("INStartCallIntent", perform: processUserActivity)
|
||||
@@ -132,6 +170,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func initAuthenticate() {
|
||||
logger.debug("initAuthenticate")
|
||||
if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil {
|
||||
userAuthorized = false
|
||||
} else if doAuthenticate {
|
||||
@@ -140,10 +179,13 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func runAuthenticate() {
|
||||
logger.debug("DEBUGGING: runAuthenticate")
|
||||
if !prefPerformLA {
|
||||
userAuthorized = true
|
||||
} else {
|
||||
logger.debug("DEBUGGING: before dismissAllSheets")
|
||||
dismissAllSheets(animated: false) {
|
||||
logger.debug("DEBUGGING: in dismissAllSheets callback")
|
||||
chatModel.chatId = nil
|
||||
justAuthenticate()
|
||||
}
|
||||
@@ -152,14 +194,18 @@ struct ContentView: View {
|
||||
|
||||
private func justAuthenticate() {
|
||||
userAuthorized = false
|
||||
authenticate(reason: NSLocalizedString("Unlock", comment: "authentication reason")) { laResult in
|
||||
let laMode = privacyLocalAuthModeDefault.get()
|
||||
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
||||
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
|
||||
switch (laResult) {
|
||||
case .success:
|
||||
userAuthorized = true
|
||||
canConnectCall = true
|
||||
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
|
||||
case .failed:
|
||||
break
|
||||
if laMode == .passcode {
|
||||
AlertManager.shared.showAlert(laFailedAlert())
|
||||
}
|
||||
case .unavailable:
|
||||
userAuthorized = true
|
||||
prefPerformLA = false
|
||||
@@ -185,25 +231,28 @@ struct ContentView: View {
|
||||
Alert(
|
||||
title: Text("SimpleX Lock"),
|
||||
message: Text("To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled."),
|
||||
primaryButton: .default(Text("Turn on")) {
|
||||
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .success:
|
||||
prefPerformLA = true
|
||||
alertManager.showAlert(laTurnedOnAlert())
|
||||
case .failed:
|
||||
prefPerformLA = false
|
||||
alertManager.showAlert(laFailedAlert())
|
||||
case .unavailable:
|
||||
prefPerformLA = false
|
||||
alertManager.showAlert(laUnavailableInstructionAlert())
|
||||
}
|
||||
}
|
||||
},
|
||||
primaryButton: .default(Text("Turn on")) { showChooseLAMode = true },
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func initialEnableLA () {
|
||||
privacyLocalAuthModeDefault.set(.system)
|
||||
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .success:
|
||||
prefPerformLA = true
|
||||
alertManager.showAlert(laTurnedOnAlert())
|
||||
case .failed:
|
||||
prefPerformLA = false
|
||||
alertManager.showAlert(laFailedAlert())
|
||||
case .unavailable:
|
||||
prefPerformLA = false
|
||||
alertManager.showAlert(laUnavailableInstructionAlert())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func notificationAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Notifications are disabled!"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -34,6 +34,10 @@ class BGManager {
|
||||
}
|
||||
|
||||
func schedule() {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.schedule: disabled")
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.schedule")
|
||||
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval)
|
||||
@@ -45,6 +49,10 @@ class BGManager {
|
||||
}
|
||||
|
||||
private func handleRefresh(_ task: BGAppRefreshTask) {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.handleRefresh: disabled")
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.handleRefresh")
|
||||
schedule()
|
||||
if appStateGroupDefault.get().inactive {
|
||||
|
||||
@@ -13,6 +13,7 @@ import SimpleXChat
|
||||
|
||||
final class ChatModel: ObservableObject {
|
||||
@Published var onboardingStage: OnboardingStage?
|
||||
@Published var setDeliveryReceipts = false
|
||||
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
||||
@Published var currentUser: User?
|
||||
@Published var users: [UserInfo] = []
|
||||
@@ -21,6 +22,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var chatDbChanged = false
|
||||
@Published var chatDbEncrypted: Bool?
|
||||
@Published var chatDbStatus: DBMigrationResult?
|
||||
@Published var laRequest: LocalAuthRequest?
|
||||
// list of chat "previews"
|
||||
@Published var chats: [Chat] = []
|
||||
// map of connections network statuses, key is agent connection id
|
||||
@@ -40,7 +42,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var tokenRegistered = false
|
||||
@Published var tokenStatus: NtfTknStatus?
|
||||
@Published var notificationMode = NotificationsMode.off
|
||||
@Published var notificationPreview: NotificationPreviewMode? = ntfPreviewModeGroupDefault.get()
|
||||
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||
@Published var incognito: Bool = incognitoGroupDefault.get()
|
||||
// pending notification actions
|
||||
@Published var ntfContactRequest: ChatId?
|
||||
@@ -56,6 +58,8 @@ final class ChatModel: ObservableObject {
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
// tracks keyboard height via subscription in AppDelegate
|
||||
@Published var keyboardHeight: CGFloat = 0
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
|
||||
@@ -65,6 +69,14 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||
|
||||
var ntfEnableLocal: Bool {
|
||||
notificationMode == .off || ntfEnableLocalGroupDefault.get()
|
||||
}
|
||||
|
||||
var ntfEnablePeriodic: Bool {
|
||||
notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get()
|
||||
}
|
||||
|
||||
func getUser(_ userId: Int64) -> User? {
|
||||
currentUser?.userId == userId
|
||||
? currentUser
|
||||
@@ -132,6 +144,14 @@ final class ChatModel: ObservableObject {
|
||||
updateChat(.direct(contact: contact), addMissing: contact.directOrUsed)
|
||||
}
|
||||
|
||||
func updateContactConnectionStats(_ contact: Contact, _ connectionStats: ConnectionStats) {
|
||||
var updatedConn = contact.activeConn
|
||||
updatedConn.connectionStats = connectionStats
|
||||
var updatedContact = contact
|
||||
updatedContact.activeConn = updatedConn
|
||||
updateContact(updatedContact)
|
||||
}
|
||||
|
||||
func updateGroup(_ groupInfo: GroupInfo) {
|
||||
updateChat(.group(groupInfo: groupInfo))
|
||||
}
|
||||
@@ -237,16 +257,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 {
|
||||
@@ -263,7 +276,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)
|
||||
@@ -276,7 +312,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)
|
||||
}
|
||||
@@ -356,7 +392,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
|
||||
@@ -404,7 +440,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)
|
||||
}
|
||||
}
|
||||
@@ -449,7 +485,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
|
||||
@@ -504,6 +540,16 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) {
|
||||
if let conn = member.activeConn {
|
||||
var updatedConn = conn
|
||||
updatedConn.connectionStats = connectionStats
|
||||
var updatedMember = member
|
||||
updatedMember.activeConn = updatedConn
|
||||
_ = upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
|
||||
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
|
||||
var i = 0
|
||||
var totalBelow = 0
|
||||
@@ -566,6 +612,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
|
||||
}
|
||||
@@ -158,6 +159,18 @@ func apiSetActiveUserAsync(_ userId: Int64, viewPwd: String?) async throws -> Us
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetAllContactReceipts(enable: Bool) async throws {
|
||||
let r = await chatSendCmd(.setAllContactReceipts(enable: enable))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetUserContactReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws {
|
||||
let r = await chatSendCmd(.apiSetUserContactReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
|
||||
try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd))
|
||||
}
|
||||
@@ -187,7 +200,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
|
||||
}
|
||||
|
||||
func apiStartChat() throws -> Bool {
|
||||
let r = chatSendCmdSync(.startChat(subscribe: true, expire: true))
|
||||
let r = chatSendCmdSync(.startChat(subscribe: true, expire: true, xftp: true))
|
||||
switch r {
|
||||
case .chatStarted: return true
|
||||
case .chatRunning: return false
|
||||
@@ -243,8 +256,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 +310,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 +329,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 +360,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) }
|
||||
@@ -447,6 +476,10 @@ func setNetworkConfig(_ cfg: NetCfg) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func reconnectAllServers() async throws {
|
||||
try await sendCommandOkResp(.reconnectAllServers)
|
||||
}
|
||||
|
||||
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
|
||||
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
||||
}
|
||||
@@ -457,18 +490,46 @@ func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profi
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) {
|
||||
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
|
||||
let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberInfo(_, _, _, connStats_) = r { return (connStats_) }
|
||||
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
|
||||
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 apiSyncContactRatchet(_ contactId: Int64, _ force: Bool) throws -> ConnectionStats {
|
||||
let r = chatSendCmdSync(.apiSyncContactRatchet(contactId: contactId, force: force))
|
||||
if case let .contactRatchetSyncStarted(_, _, connectionStats) = r { return connectionStats }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSyncGroupMemberRatchet(_ groupId: Int64, _ groupMemberId: Int64, _ force: Bool) throws -> (GroupMember, ConnectionStats) {
|
||||
let r = chatSendCmdSync(.apiSyncGroupMemberRatchet(groupId: groupId, groupMemberId: groupMemberId, force: force))
|
||||
if case let .groupMemberRatchetSyncStarted(_, _, member, connectionStats) = r { return (member, connectionStats) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
|
||||
@@ -504,59 +565,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))"
|
||||
)
|
||||
@@ -618,6 +690,16 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? {
|
||||
}
|
||||
}
|
||||
|
||||
func apiSetProfileAddress(on: Bool) async throws -> User? {
|
||||
let userId = try currentUserId("apiSetProfileAddress")
|
||||
let r = await chatSendCmd(.apiSetProfileAddress(userId: userId, on: on))
|
||||
switch r {
|
||||
case .userProfileNoChange: return nil
|
||||
case let .userProfileUpdated(user, _, _): return user
|
||||
default: throw r
|
||||
}
|
||||
}
|
||||
|
||||
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
|
||||
let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
|
||||
if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact }
|
||||
@@ -643,10 +725,10 @@ func apiCreateUserAddress() async throws -> String {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteUserAddress() async throws {
|
||||
func apiDeleteUserAddress() async throws -> User? {
|
||||
let userId = try currentUserId("apiDeleteUserAddress")
|
||||
let r = await chatSendCmd(.apiDeleteMyAddress(userId: userId))
|
||||
if case .userContactLinkDeleted = r { return }
|
||||
if case let .userContactLinkDeleted(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -688,7 +770,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",
|
||||
@@ -727,7 +811,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)):
|
||||
@@ -745,6 +831,7 @@ func apiReceiveFile(fileId: Int64, inline: Bool? = nil) async -> AChatItem? {
|
||||
func cancelFile(user: User, fileId: Int64) async {
|
||||
if let chatItem = await apiCancelFile(fileId: fileId) {
|
||||
DispatchQueue.main.async { chatItemSimpleUpdate(user, chatItem) }
|
||||
cleanupFile(chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,23 +846,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,7 +928,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)
|
||||
@@ -858,7 +944,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))")
|
||||
}
|
||||
@@ -942,12 +1030,6 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
|
||||
return []
|
||||
}
|
||||
|
||||
func apiListMembersSync(_ groupId: Int64) -> [GroupMember] {
|
||||
let r = chatSendCmdSync(.apiListMembers(groupId: groupId))
|
||||
if case let .groupMembers(_, group) = r { return group.members }
|
||||
return []
|
||||
}
|
||||
|
||||
func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] {
|
||||
let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil }
|
||||
return ChatModel.shared.chats
|
||||
@@ -1020,6 +1102,8 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
if m.currentUser == nil {
|
||||
onboardingStageDefault.set(.step1_SimpleXInfo)
|
||||
privacyDeliveryReceiptsSet.set(true)
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
} else if start {
|
||||
try startChat(refreshInvitations: refreshInvitations)
|
||||
@@ -1045,9 +1129,13 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
registerToken(token: token)
|
||||
}
|
||||
withAnimation {
|
||||
m.onboardingStage = m.onboardingStage == .step2_CreateProfile && m.users.count == 1
|
||||
? .step3_SetNotificationsMode
|
||||
: .onboardingComplete
|
||||
let savedOnboardingStage = onboardingStageDefault.get()
|
||||
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
|
||||
? .step3_CreateSimpleXAddress
|
||||
: savedOnboardingStage
|
||||
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
|
||||
m.setDeliveryReceipts = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatReceiver.shared.start()
|
||||
@@ -1227,15 +1315,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 {
|
||||
@@ -1244,8 +1326,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
case let .chatItemStatusUpdated(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if !cItem.isDeletedContent && (!active(user) || m.upsertChatItem(cInfo, cItem)) {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
if !cItem.isDeletedContent {
|
||||
let added = active(user) ? m.upsertChatItem(cInfo, cItem) : true
|
||||
if added && cItem.showNotification {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
}
|
||||
}
|
||||
if let endTask = m.messageDelivery[cItem.id] {
|
||||
switch cItem.meta.itemStatus {
|
||||
@@ -1257,6 +1342,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 {
|
||||
@@ -1311,10 +1400,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)
|
||||
@@ -1323,37 +1415,36 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
if active(user) {
|
||||
m.updateGroup(groupInfo)
|
||||
}
|
||||
case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileStart(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileComplete(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileSndCancelled(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupFile(aChatItem)
|
||||
case let .rcvFileProgressXFTP(user, aChatItem, _, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .rcvFileError(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupFile(aChatItem)
|
||||
case let .sndFileStart(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileComplete(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
let cItem = aChatItem.chatItem
|
||||
let mc = cItem.content.msgContent
|
||||
if aChatItem.chatInfo.chatType == .direct,
|
||||
case .file = mc,
|
||||
let fileName = cItem.file?.filePath {
|
||||
removeFile(fileName)
|
||||
}
|
||||
cleanupDirectFile(aChatItem)
|
||||
case let .sndFileRcvCancelled(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupDirectFile(aChatItem)
|
||||
case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .sndFileCompleteXFTP(user, aChatItem, _):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
let cItem = aChatItem.chatItem
|
||||
let mc = cItem.content.msgContent
|
||||
if case .file = mc,
|
||||
let fileName = cItem.file?.filePath {
|
||||
removeFile(fileName)
|
||||
}
|
||||
cleanupFile(aChatItem)
|
||||
case let .sndFileError(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupFile(aChatItem)
|
||||
case let .callInvitation(invitation):
|
||||
m.callInvitations[invitation.contact.id] = invitation
|
||||
activateCall(invitation)
|
||||
@@ -1393,6 +1484,14 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
case .chatSuspended:
|
||||
chatSuspended()
|
||||
case let .contactSwitch(_, contact, switchProgress):
|
||||
m.updateContactConnectionStats(contact, switchProgress.connectionStats)
|
||||
case let .groupMemberSwitch(_, groupInfo, member, switchProgress):
|
||||
m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
|
||||
case let .contactRatchetSync(_, contact, ratchetSyncProgress):
|
||||
m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
|
||||
case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress):
|
||||
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
|
||||
default:
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
|
||||
@@ -76,9 +76,11 @@ private func _chatSuspended() {
|
||||
}
|
||||
|
||||
func activateChat(appState: AppState = .active) {
|
||||
logger.debug("DEBUGGING: activateChat")
|
||||
suspendLockQueue.sync {
|
||||
appStateGroupDefault.set(appState)
|
||||
if ChatModel.ok { apiActivateChat() }
|
||||
logger.debug("DEBUGGING: activateChat: after apiActivateChat")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,10 +97,14 @@ func initChatAndMigrate(refreshInvitations: Bool = true) {
|
||||
}
|
||||
|
||||
func startChatAndActivate() {
|
||||
logger.debug("DEBUGGING: startChatAndActivate")
|
||||
if ChatModel.shared.chatRunning == true {
|
||||
ChatReceiver.shared.start()
|
||||
logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start")
|
||||
}
|
||||
if .active != appStateGroupDefault.get() {
|
||||
logger.debug("DEBUGGING: startChatAndActivate: before activateChat")
|
||||
activateChat()
|
||||
logger.debug("DEBUGGING: startChatAndActivate: after activateChat")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))")
|
||||
@@ -106,7 +116,8 @@ struct SimpleXApp: App {
|
||||
|
||||
private func authenticationExpired() -> Bool {
|
||||
if let enteredBackground = enteredBackground {
|
||||
return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30
|
||||
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
|
||||
return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -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])) {
|
||||
@@ -58,6 +57,37 @@ private func serverHost(_ s: String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
enum SendReceipts: Identifiable, Hashable {
|
||||
case yes
|
||||
case no
|
||||
case userDefault(Bool)
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var text: LocalizedStringKey {
|
||||
switch self {
|
||||
case .yes: return "yes"
|
||||
case .no: return "no"
|
||||
case let .userDefault(on): return on ? "default (yes)" : "default (no)"
|
||||
}
|
||||
}
|
||||
|
||||
func bool() -> Bool? {
|
||||
switch self {
|
||||
case .yes: return true
|
||||
case .no: return false
|
||||
case .userDefault: return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func fromBool(_ enable: Bool?, userDefault def: Bool) -> SendReceipts {
|
||||
if let enable = enable {
|
||||
return enable ? .yes : .no
|
||||
}
|
||||
return .userDefault(def)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@@ -69,6 +99,8 @@ struct ChatInfoView: View {
|
||||
@Binding var connectionCode: String?
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: ChatInfoViewAlert? = nil
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum ChatInfoViewAlert: Identifiable {
|
||||
@@ -76,6 +108,8 @@ struct ChatInfoView: View {
|
||||
case clearChatAlert
|
||||
case networkStatusAlert
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case syncConnectionForceAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
@@ -84,6 +118,8 @@ struct ChatInfoView: View {
|
||||
case .clearChatAlert: return "clearChatAlert"
|
||||
case .networkStatusAlert: return "networkStatusAlert"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
@@ -114,6 +150,29 @@ struct ChatInfoView: View {
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
sendReceiptsOption()
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
QRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [contactLink])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Servers") {
|
||||
@@ -121,12 +180,25 @@ 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 }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +217,20 @@ struct ChatInfoView: View {
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.onAppear {
|
||||
if let currentUser = chatModel.currentUser {
|
||||
sendReceiptsUserDefault = currentUser.sendRcptsContacts
|
||||
}
|
||||
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||
}
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case .deleteContactAlert: return deleteContactAlert()
|
||||
case .clearChatAlert: return clearChatAlert()
|
||||
case .networkStatusAlert: return networkStatusAlert()
|
||||
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
@@ -163,20 +243,30 @@ struct ChatInfoView: View {
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
HStack {
|
||||
if contact.verified {
|
||||
Image(systemName: "checkmark.shield")
|
||||
if contact.verified {
|
||||
(
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.font(.title2)
|
||||
+ Text(" ")
|
||||
+ Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.bottom, 2)
|
||||
} else {
|
||||
Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
|
||||
Text(cInfo.fullName)
|
||||
.font(.title2)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -256,6 +346,44 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendReceiptsOption() -> some View {
|
||||
Picker(selection: $sendReceipts) {
|
||||
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
|
||||
Text(opt.text)
|
||||
}
|
||||
} label: {
|
||||
Label("Send receipts", systemImage: "checkmark.message")
|
||||
}
|
||||
.frame(height: 36)
|
||||
.onChange(of: sendReceipts) { _ in
|
||||
setSendReceipts()
|
||||
}
|
||||
}
|
||||
|
||||
private func setSendReceipts() {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.sendRcpts = sendReceipts.bool()
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
syncContactConnection(force: false)
|
||||
} label: {
|
||||
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButtonForce() -> some View {
|
||||
Button {
|
||||
alert = .syncConnectionForceAlert
|
||||
} label: {
|
||||
Label("Renegotiate encryption", systemImage: "exclamationmark.triangle")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func networkStatusRow() -> some View {
|
||||
HStack {
|
||||
Text("Network status")
|
||||
@@ -344,7 +472,12 @@ struct ChatInfoView: View {
|
||||
private func switchContactAddress() {
|
||||
Task {
|
||||
do {
|
||||
try await apiSwitchContact(contactId: contact.apiId)
|
||||
let stats = try apiSwitchContact(contactId: contact.apiId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateContactConnectionStats(contact, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("switchContactAddress apiSwitchContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -354,13 +487,68 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func abortSwitchContactAddress() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiAbortSwitchContact(contact.apiId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateContactConnectionStats(contact, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("abortSwitchContactAddress apiAbortSwitchContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error aborting address change")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncContactConnection(force: Bool) {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, force)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateContactConnectionStats(contact, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
||||
func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Alert {
|
||||
Alert(
|
||||
title: Text("Renegotiate encryption?"),
|
||||
message: Text("The encryption is working and the new encryption agreement is not required. It may result in connection errors!"),
|
||||
primaryButton: .destructive(Text("Renegotiate"), action: syncConnectionForce),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,11 +55,14 @@ struct CIFileView: View {
|
||||
case .sndTransfer: return false
|
||||
case .sndComplete: return false
|
||||
case .sndCancelled: return false
|
||||
case .sndError: return false
|
||||
case .rcvInvitation: return true
|
||||
case .rcvAccepted: return true
|
||||
case .rcvTransfer: return false
|
||||
case .rcvComplete: return true
|
||||
case .rcvCancelled: return false
|
||||
case .rcvError: return false
|
||||
case .invalid: return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -130,6 +133,7 @@ struct CIFileView: View {
|
||||
}
|
||||
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
||||
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .rcvInvitation:
|
||||
if fileSizeValid() {
|
||||
fileIcon("arrow.down.doc.fill", color: .accentColor)
|
||||
@@ -145,6 +149,8 @@ struct CIFileView: View {
|
||||
}
|
||||
case .rcvComplete: fileIcon("doc.fill")
|
||||
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
||||
}
|
||||
} else {
|
||||
fileIcon("doc.fill")
|
||||
|
||||
@@ -93,10 +93,13 @@ struct CIImageView: View {
|
||||
case .sndTransfer: progressView()
|
||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||
case .sndError: fileIcon("xmark", 10, 13)
|
||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||
case .rcvTransfer: progressView()
|
||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||
case .rcvError: fileIcon("xmark", 10, 13)
|
||||
case .invalid: fileIcon("questionmark", 10, 13)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,30 @@ struct CIMetaView: View {
|
||||
if chatItem.isDeletedContent {
|
||||
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
|
||||
} else {
|
||||
ciMetaText(chatItem.meta, chatTTL: chat.chatInfo.timedMessagesTTL, color: metaColor)
|
||||
let meta = chatItem.meta
|
||||
let ttl = chat.chatInfo.timedMessagesTTL
|
||||
switch meta.itemStatus {
|
||||
case .sndSent:
|
||||
ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .sent)
|
||||
case .sndRcvd:
|
||||
ZStack {
|
||||
ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd1)
|
||||
ciMetaText(meta, chatTTL: ttl, color: metaColor, sent: .rcvd2)
|
||||
}
|
||||
default:
|
||||
ciMetaText(meta, chatTTL: ttl, color: metaColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparent: Bool = false) -> Text {
|
||||
enum SentCheckmark {
|
||||
case sent
|
||||
case rcvd1
|
||||
case rcvd2
|
||||
}
|
||||
|
||||
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text {
|
||||
var r = Text("")
|
||||
if meta.itemEdited {
|
||||
r = r + statusIconText("pencil", color)
|
||||
@@ -32,12 +50,21 @@ 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(" ")
|
||||
}
|
||||
if let (icon, statusColor) = meta.statusIcon(color) {
|
||||
r = r + statusIconText(icon, transparent ? .clear : statusColor) + Text(" ")
|
||||
let t = Text(Image(systemName: icon)).font(.caption2)
|
||||
let gap = Text(" ").kerning(-1.25)
|
||||
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
||||
switch sent {
|
||||
case nil: r = r + t1
|
||||
case .sent: r = r + t1 + gap
|
||||
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : color.opacity(0.67)) + gap
|
||||
case .rcvd2: r = r + gap + t1
|
||||
}
|
||||
r = r + Text(" ")
|
||||
} else if !meta.disappearing {
|
||||
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//
|
||||
// CIRcvDecryptionError.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 15/04/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup."
|
||||
|
||||
struct CIRcvDecryptionError: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
var msgDecryptError: MsgDecryptError
|
||||
var msgCount: UInt32
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
@State private var alert: CIRcvDecryptionErrorAlert?
|
||||
|
||||
enum CIRcvDecryptionErrorAlert: Identifiable {
|
||||
case syncAllowedAlert(_ syncConnection: () -> Void)
|
||||
case syncNotSupportedContactAlert
|
||||
case syncNotSupportedMemberAlert
|
||||
case decryptionErrorAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .syncAllowedAlert: return "syncAllowedAlert"
|
||||
case .syncNotSupportedContactAlert: return "syncNotSupportedContactAlert"
|
||||
case .syncNotSupportedMemberAlert: return "syncNotSupportedMemberAlert"
|
||||
case .decryptionErrorAlert: return "decryptionErrorAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
viewBody()
|
||||
.onAppear {
|
||||
// for direct chat ConnectionStats are populated on opening chat, see ChatView onAppear
|
||||
if case let .group(groupInfo) = chat.chatInfo,
|
||||
case let .groupRcv(groupMember) = chatItem.chatDir {
|
||||
do {
|
||||
let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
|
||||
if let s = stats {
|
||||
ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, member, s)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case let .syncAllowedAlert(syncConnection): return syncAllowedAlert(syncConnection)
|
||||
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
|
||||
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
|
||||
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
let contactStats = contact.activeConn.connectionStats {
|
||||
if contactStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
||||
}
|
||||
} else if !contactStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedContactAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
} else if case let .group(groupInfo) = chat.chatInfo,
|
||||
case let .groupRcv(groupMember) = chatItem.chatDir,
|
||||
let modelMember = ChatModel.shared.groupMembers.first(where: { $0.id == groupMember.id }),
|
||||
let memberStats = modelMember.activeConn?.connectionStats {
|
||||
if memberStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
|
||||
}
|
||||
} else if !memberStats.ratchetSyncSupported {
|
||||
decryptionErrorItemFixButton(syncSupported: false) {
|
||||
alert = .syncNotSupportedMemberAlert
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
} else {
|
||||
basicDecryptionErrorItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func basicDecryptionErrorItem() -> some View {
|
||||
decryptionErrorItem { alert = .decryptionErrorAlert }
|
||||
}
|
||||
|
||||
private func decryptionErrorItemFixButton(syncSupported: Bool, _ onClick: @escaping (() -> Void)) -> some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).fontWeight(.medium) + Text(": ")
|
||||
}
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
}
|
||||
(
|
||||
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ Text("Fix connection")
|
||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, transparent: true)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
private func decryptionErrorItem(_ onClick: @escaping (() -> Void)) -> some View {
|
||||
func text() -> Text {
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
+ Text(" ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, transparent: true)
|
||||
}
|
||||
return ZStack(alignment: .bottomTrailing) {
|
||||
HStack {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
Text(member).fontWeight(.medium) + Text(": ") + text()
|
||||
} else {
|
||||
text()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
private func message() -> Text {
|
||||
var message: Text
|
||||
let why = Text(decryptErrorReason)
|
||||
switch msgDecryptError {
|
||||
case .ratchetHeader:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
case .tooManySkipped:
|
||||
message = Text("\(msgCount) messages skipped.") + Text("\n") + why
|
||||
case .ratchetEarlier:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
case .other:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
private func syncMemberConnection(_ groupInfo: GroupInfo, _ member: GroupMember) {
|
||||
Task {
|
||||
do {
|
||||
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, mem, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncContactConnection(_ contact: Contact) {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, false)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContactConnectionStats(contact, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncAllowedAlert(_ syncConnection: @escaping () -> Void) -> Alert {
|
||||
Alert(
|
||||
title: Text("Fix connection?"),
|
||||
message: message(),
|
||||
primaryButton: .default(Text("Fix"), action: syncConnection),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//struct CIRcvDecryptionError_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// CIRcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 1, chatItem: ChatItem.getIntegrityErrorSample())
|
||||
// }
|
||||
//}
|
||||
@@ -199,38 +199,34 @@ struct CIVideoView: View {
|
||||
case .xftp: progressCircle(sndProgress, sndTotal)
|
||||
case .smp: progressView()
|
||||
}
|
||||
case .sndComplete:
|
||||
Image(systemName: "checkmark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 10, height: 10)
|
||||
.foregroundColor(.white)
|
||||
.padding(13)
|
||||
case .rcvInvitation:
|
||||
Image(systemName: "arrow.down")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.white)
|
||||
.padding(11)
|
||||
case .rcvAccepted:
|
||||
Image(systemName: "ellipsis")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.white)
|
||||
.padding(11)
|
||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||
case .sndError: fileIcon("xmark", 10, 13)
|
||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||
case let .rcvTransfer(rcvProgress, rcvTotal):
|
||||
if file.fileProtocol == .xftp && rcvProgress < rcvTotal {
|
||||
progressCircle(rcvProgress, rcvTotal)
|
||||
} else {
|
||||
progressView()
|
||||
}
|
||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||
case .rcvError: fileIcon("xmark", 10, 13)
|
||||
case .invalid: fileIcon("questionmark", 10, 13)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(.white)
|
||||
.padding(padding)
|
||||
}
|
||||
|
||||
private func progressView() -> some View {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
@@ -256,7 +252,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 {
|
||||
@@ -108,28 +137,37 @@ struct VoiceMessagePlayer: View {
|
||||
case .sndTransfer: playbackButton()
|
||||
case .sndComplete: playbackButton()
|
||||
case .sndCancelled: playbackButton()
|
||||
case .rcvInvitation: loadingIcon()
|
||||
case .sndError: playbackButton()
|
||||
case .rcvInvitation: downloadButton(recordingFile)
|
||||
case .rcvAccepted: loadingIcon()
|
||||
case .rcvTransfer: loadingIcon()
|
||||
case .rcvComplete: playbackButton()
|
||||
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
}
|
||||
} else {
|
||||
playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
@@ -177,6 +215,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?
|
||||
@@ -202,7 +252,6 @@ struct VoiceMessagePlayer: View {
|
||||
}
|
||||
|
||||
private func startPlayback(_ recordingFileName: String) {
|
||||
startingPlayback = true
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
@@ -211,8 +260,7 @@ struct VoiceMessagePlayer: View {
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileName: recordingFileName)
|
||||
playbackTime = TimeInterval(0)
|
||||
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
|
||||
playbackState = .playing
|
||||
}
|
||||
}
|
||||
@@ -238,13 +286,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
@@ -10,9 +10,53 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct IntegrityErrorItemView: View {
|
||||
var msgError: MsgErrorType
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
CIMsgError(chatItem: chatItem, showMember: showMember) {
|
||||
switch msgError {
|
||||
case .msgSkipped:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Skipped messages",
|
||||
message: """
|
||||
It can happen when:
|
||||
1. The messages expired in the sending client after 2 days or on the server after 30 days.
|
||||
2. Message decryption failed, because you or your contact used old database backup.
|
||||
3. The connection was compromised.
|
||||
"""
|
||||
)
|
||||
case .msgBadHash:
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Bad message hash"),
|
||||
message: Text("The hash of the previous message is different.") + Text("\n") +
|
||||
Text(decryptErrorReason) + Text("\n") +
|
||||
Text("Please report it to the developers.")
|
||||
))
|
||||
case .msgBadId: msgBadIdAlert()
|
||||
case .msgDuplicate: msgBadIdAlert()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func msgBadIdAlert() {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Bad message ID"),
|
||||
message: Text("""
|
||||
The ID of the next message is incorrect (less or equal to the previous).
|
||||
It can happen because of some bug or when the connection is compromised.
|
||||
""") + Text("\n") +
|
||||
Text("Please report it to the developers.")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct CIMsgError: View {
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
var onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if showMember, let member = chatItem.memberDisplayName {
|
||||
@@ -29,26 +73,12 @@ struct IntegrityErrorItemView: View {
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
.onTapGesture { skippedMessagesAlert() }
|
||||
}
|
||||
|
||||
private func skippedMessagesAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Skipped messages",
|
||||
message: """
|
||||
It can happen when:
|
||||
1. The messages expire on the server if they were not received for 30 days,
|
||||
2. The server you use to receive the messages from this contact was updated and restarted.
|
||||
3. The connection is compromised.
|
||||
Please connect to the developers via Settings to receive the updates about the servers.
|
||||
We will be adding server redundancy to prevent lost messages.
|
||||
"""
|
||||
)
|
||||
.onTapGesture(perform: onTap)
|
||||
}
|
||||
}
|
||||
|
||||
struct IntegrityErrorItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
IntegrityErrorItemView(chatItem: ChatItem.getIntegrityErrorSample())
|
||||
IntegrityErrorItemView(msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,297 @@
|
||||
//
|
||||
// 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?
|
||||
@State private var selection: CIInfoTab = .history
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum CIInfoTab {
|
||||
case history
|
||||
case quote
|
||||
}
|
||||
|
||||
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 {
|
||||
if let qi = ci.quotedItem {
|
||||
TabView(selection: $selection) {
|
||||
historyTab()
|
||||
.tabItem {
|
||||
Label("History", systemImage: "clock")
|
||||
}
|
||||
.tag(CIInfoTab.history)
|
||||
quoteTab(qi)
|
||||
.tabItem {
|
||||
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
.tag(CIInfoTab.quote)
|
||||
}
|
||||
} else {
|
||||
historyTab()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func details() -> some View {
|
||||
let meta = ci.meta
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(title)
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
|
||||
infoRow("Sent at", localTimestamp(meta.itemTs))
|
||||
if !ci.chatDir.sent {
|
||||
infoRow("Received at", localTimestamp(meta.createdAt))
|
||||
}
|
||||
switch (meta.itemDeleted) {
|
||||
case let .deleted(deletedTs):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Deleted at", localTimestamp(deletedTs))
|
||||
}
|
||||
case let .moderated(deletedTs, _):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Moderated at", localTimestamp(deletedTs))
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
if let deleteAt = meta.itemTimed?.deleteAt {
|
||||
infoRow("Disappears at", localTimestamp(deleteAt))
|
||||
}
|
||||
if developerTools {
|
||||
infoRow("Database ID", "\(meta.itemId)")
|
||||
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func historyTab() -> some View {
|
||||
GeometryReader { g in
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
Divider().padding(.vertical)
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Text("No history")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||
.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 textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
|
||||
if text != "" {
|
||||
messageText(text, formattedText, sender)
|
||||
} else {
|
||||
Text("no text")
|
||||
.italic()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View {
|
||||
GeometryReader { g in
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
details()
|
||||
Divider().padding(.vertical)
|
||||
Text("In reply to")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 4)
|
||||
quotedMsgView(qi, maxWidth)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(quotedMsgFrameColor(qi, colorScheme))
|
||||
.cornerRadius(18)
|
||||
.contextMenu {
|
||||
if qi.text != "" {
|
||||
Button {
|
||||
showShareSheet(items: [qi.text])
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Button {
|
||||
UIPasteboard.general.string = qi.text
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(localTimestamp(qi.sentAt))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
|
||||
(qi.chatDir?.sent ?? false)
|
||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
}
|
||||
|
||||
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 qi = ci.quotedItem {
|
||||
shareText += ["", NSLocalizedString("In reply to", comment: "copied message info")]
|
||||
let t = qi.text
|
||||
shareText += [""]
|
||||
if let sender = qi.getSender(nil) {
|
||||
shareText += [String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@ at %@:", comment: "copied message info, <sender> at <time>"),
|
||||
sender,
|
||||
localTimestamp(qi.sentAt)
|
||||
)]
|
||||
} else {
|
||||
shareText += [String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@:", comment: "copied message info"),
|
||||
localTimestamp(qi.sentAt)
|
||||
)]
|
||||
}
|
||||
shareText += [t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")]
|
||||
}
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
shareText += ["", NSLocalizedString("History", comment: "copied message info")]
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +71,8 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case .rcvDeleted: deletedItemView()
|
||||
case let .sndCall(status, duration): callItemView(status, duration)
|
||||
case let .rcvCall(status, duration): callItemView(status, duration)
|
||||
case .rcvIntegrityError: IntegrityErrorItemView(chatItem: chatItem, showMember: showMember)
|
||||
case let .rcvIntegrityError(msgError): IntegrityErrorItemView(msgError: msgError, chatItem: chatItem, showMember: showMember)
|
||||
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem, showMember: showMember)
|
||||
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case .rcvGroupEvent: eventItemView()
|
||||
@@ -108,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))
|
||||
}
|
||||
@@ -125,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
|
||||
@@ -136,7 +153,18 @@ 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, "1 skipped message", .rcvRead),
|
||||
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
),
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
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
|
||||
@@ -147,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
|
||||
@@ -158,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
|
||||
|
||||
@@ -22,7 +22,7 @@ struct ChatView: View {
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var composeState = ComposeState()
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var keyboardVisible = false
|
||||
@State private var showDeleteMessage = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var customUserProfile: Profile?
|
||||
@@ -39,6 +39,16 @@ struct ChatView: View {
|
||||
@State private var selectedMember: GroupMember? = nil
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.keyboardPadding()
|
||||
} else {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
|
||||
private var viewBody: some View {
|
||||
let cInfo = chat.chatInfo
|
||||
return VStack(spacing: 0) {
|
||||
if searchMode {
|
||||
@@ -65,17 +75,14 @@ struct ChatView: View {
|
||||
.navigationTitle(cInfo.chatViewName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft {
|
||||
composeState = draft
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
Task {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
}
|
||||
}
|
||||
initChatView()
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
if chatModel.chatId == nil { dismiss() }
|
||||
.onChange(of: chatModel.chatId) { cId in
|
||||
if cId != nil {
|
||||
initChatView()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
VideoPlayerView.players.removeAll()
|
||||
@@ -140,12 +147,16 @@ struct ChatView: View {
|
||||
switch cInfo {
|
||||
case let .direct(contact):
|
||||
HStack {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
if contact.allowsFeature(.calls) {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
}
|
||||
Menu {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
if contact.allowsFeature(.calls) {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
}
|
||||
searchButton()
|
||||
toggleNtfsButton(chat)
|
||||
@@ -181,6 +192,32 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func initChatView() {
|
||||
let cInfo = chat.chatInfo
|
||||
if case let .direct(contact) = cInfo {
|
||||
Task {
|
||||
do {
|
||||
let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId)
|
||||
await MainActor.run {
|
||||
if let s = stats {
|
||||
chatModel.updateContactConnectionStats(contact, s)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiContactInfo error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft {
|
||||
composeState = draft
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
Task {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func searchToolbar() -> some View {
|
||||
HStack {
|
||||
HStack {
|
||||
@@ -189,7 +226,7 @@ struct ChatView: View {
|
||||
.focused($searchFocussed)
|
||||
.foregroundColor(.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
@@ -200,7 +237,7 @@ struct ChatView: View {
|
||||
.foregroundColor(.secondary)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(10.0)
|
||||
|
||||
|
||||
Button ("Cancel") {
|
||||
searchText = ""
|
||||
searchMode = false
|
||||
@@ -215,17 +252,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 {
|
||||
@@ -431,6 +476,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
|
||||
@@ -441,16 +487,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)
|
||||
@@ -463,11 +523,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())
|
||||
}
|
||||
@@ -487,13 +605,14 @@ struct ChatView: View {
|
||||
if ci.meta.editable && !mc.isVoice && !live {
|
||||
menu.append(editAction())
|
||||
}
|
||||
menu.append(viewInfoUIAction())
|
||||
if revealed {
|
||||
menu.append(hideUIAction())
|
||||
}
|
||||
if ci.meta.itemDeleted == nil,
|
||||
let file = ci.file,
|
||||
file.cancellable {
|
||||
menu.append(cancelFileUIAction(file.fileId))
|
||||
let cancelAction = file.cancelAction {
|
||||
menu.append(cancelFileUIAction(file.fileId, cancelAction))
|
||||
}
|
||||
if !live || !ci.meta.isLive {
|
||||
menu.append(deleteUIAction())
|
||||
@@ -505,8 +624,10 @@ struct ChatView: View {
|
||||
if !ci.isDeletedContent {
|
||||
menu.append(revealUIAction())
|
||||
}
|
||||
menu.append(viewInfoUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
} else if ci.isDeletedContent {
|
||||
menu.append(viewInfoUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
}
|
||||
return menu
|
||||
@@ -526,7 +647,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"),
|
||||
@@ -585,16 +758,36 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelFileUIAction(_ fileId: Int64) -> UIAction {
|
||||
private func viewInfoUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Cancel", comment: "chat item action"),
|
||||
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,
|
||||
image: UIImage(systemName: "xmark"),
|
||||
attributes: [.destructive]
|
||||
) { _ in
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Cancel file transfer?"),
|
||||
message: Text("File transfer will be cancelled. If it's in progress it will be stoppped."),
|
||||
primaryButton: .destructive(Text("Confirm")) {
|
||||
title: Text(cancelAction.alert.title),
|
||||
message: Text(cancelAction.alert.message),
|
||||
primaryButton: .destructive(Text(cancelAction.alert.confirm)) {
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await cancelFile(user: user, fileId: fileId)
|
||||
@@ -665,7 +858,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
|
||||
@@ -738,9 +931,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 {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -154,7 +154,7 @@ struct ComposeState {
|
||||
}
|
||||
|
||||
var attachmentDisabled: Bool {
|
||||
if editing || liveMessage != nil { return true }
|
||||
if editing || liveMessage != nil || inProgress { return true }
|
||||
switch preview {
|
||||
case .noPreview: return false
|
||||
case .linkPreview: return false
|
||||
@@ -234,15 +234,13 @@ struct ComposeView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var composeState: ComposeState
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
@Binding var keyboardVisible: Bool
|
||||
|
||||
@State var linkUrl: URL? = nil
|
||||
@State var prevLinkUrl: URL? = nil
|
||||
@State var pendingLinkUrl: URL? = nil
|
||||
@State var cancelledLinks: Set<String> = []
|
||||
|
||||
@AppStorage(GROUP_DEFAULT_XFTP_SEND_ENABLED, store: groupDefaults) private var xftpSendEnabled = false
|
||||
|
||||
@State private var showChooseSource = false
|
||||
@State private var showMediaPicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@@ -266,7 +264,7 @@ struct ComposeView: View {
|
||||
default: previewView()
|
||||
}
|
||||
HStack (alignment: .bottom) {
|
||||
Button {
|
||||
let b = Button {
|
||||
showChooseSource = true
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
@@ -276,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,
|
||||
@@ -298,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
|
||||
)
|
||||
@@ -427,8 +437,10 @@ struct ComposeView: View {
|
||||
&& (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) {
|
||||
cancelCurrentVoiceRecording()
|
||||
clearCurrentDraft()
|
||||
sendMessage()
|
||||
sendMessage(ttl: nil)
|
||||
resetLinkPreview()
|
||||
} else if (composeState.inProgress) {
|
||||
clearCurrentDraft()
|
||||
} else if !composeState.empty {
|
||||
saveCurrentDraft()
|
||||
} else {
|
||||
@@ -462,8 +474,7 @@ struct ComposeView: View {
|
||||
}
|
||||
|
||||
private var maxFileSize: Int64 {
|
||||
let fileProtocol: FileProtocol = xftpSendEnabled ? .xftp : .smp
|
||||
return getMaxFileSize(fileProtocol)
|
||||
getMaxFileSize(.xftp)
|
||||
}
|
||||
|
||||
private func sendLiveMessage() async {
|
||||
@@ -471,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))
|
||||
}
|
||||
@@ -487,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))
|
||||
}
|
||||
@@ -525,7 +536,11 @@ struct ComposeView: View {
|
||||
case .noPreview:
|
||||
EmptyView()
|
||||
case let .linkPreview(linkPreview: preview):
|
||||
ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview)
|
||||
ComposeLinkView(
|
||||
linkPreview: preview,
|
||||
cancelPreview: cancelLinkPreview,
|
||||
cancelEnabled: !composeState.inProgress
|
||||
)
|
||||
case let .mediaPreviews(mediaPreviews: media):
|
||||
ComposeImageView(
|
||||
images: media.map { (img, _) in img },
|
||||
@@ -533,7 +548,7 @@ struct ComposeView: View {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
chosenMedia = []
|
||||
},
|
||||
cancelEnabled: !composeState.editing)
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||
case let .voicePreview(recordingFileName, _):
|
||||
ComposeVoiceView(
|
||||
recordingFileName: recordingFileName,
|
||||
@@ -543,7 +558,7 @@ struct ComposeView: View {
|
||||
cancelVoiceMessageRecording($0)
|
||||
clearState()
|
||||
},
|
||||
cancelEnabled: !composeState.editing,
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress,
|
||||
stopPlayback: $stopPlayback
|
||||
)
|
||||
case let .filePreview(fileName, _):
|
||||
@@ -552,7 +567,7 @@ struct ComposeView: View {
|
||||
cancelFile: {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
},
|
||||
cancelEnabled: !composeState.editing)
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,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
|
||||
@@ -603,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -650,17 +665,21 @@ struct ComposeView: View {
|
||||
if let oldMsgContent = ei.content.msgContent {
|
||||
do {
|
||||
let mc = updateMsgContent(oldMsgContent)
|
||||
let chatItem = try await apiUpdateChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: ei.id,
|
||||
msg: mc,
|
||||
live: live
|
||||
)
|
||||
await MainActor.run {
|
||||
_ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
|
||||
if mc != oldMsgContent || (ei.meta.itemLive ?? false) {
|
||||
let chatItem = try await apiUpdateChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: ei.id,
|
||||
msg: mc,
|
||||
live: live
|
||||
)
|
||||
await MainActor.run {
|
||||
_ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
|
||||
}
|
||||
return chatItem
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
return chatItem
|
||||
} catch {
|
||||
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
|
||||
AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))")
|
||||
@@ -688,30 +707,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)
|
||||
@@ -719,6 +739,9 @@ struct ComposeView: View {
|
||||
}
|
||||
return chatItem
|
||||
}
|
||||
if let file = file {
|
||||
removeFile(file)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -924,19 +947,18 @@ struct ComposeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
@State var composeState = ComposeState(message: "hello")
|
||||
@FocusState var keyboardVisible: Bool
|
||||
|
||||
return Group {
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: $keyboardVisible
|
||||
keyboardVisible: Binding.constant(true)
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
ComposeView(
|
||||
chat: chat,
|
||||
composeState: $composeState,
|
||||
keyboardVisible: $keyboardVisible
|
||||
keyboardVisible: Binding.constant(true)
|
||||
)
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ import PhotosUI
|
||||
|
||||
struct NativeTextEditor: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
@Binding var disableEditing: Bool
|
||||
let height: CGFloat
|
||||
let font: UIFont
|
||||
@FocusState.Binding var focused: Bool
|
||||
@Binding var focused: Bool
|
||||
let alignment: TextAlignment
|
||||
let onImagesAdded: ([UploadContent]) -> Void
|
||||
|
||||
@@ -26,7 +27,11 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
field.textAlignment = alignment == .leading ? .left : .right
|
||||
field.autocapitalizationType = .sentences
|
||||
field.setOnTextChangedListener { newText, images in
|
||||
text = newText
|
||||
if !disableEditing {
|
||||
text = newText
|
||||
} else {
|
||||
field.text = text
|
||||
}
|
||||
if !images.isEmpty {
|
||||
onImagesAdded(images)
|
||||
}
|
||||
@@ -99,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) {
|
||||
@@ -137,12 +144,12 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
|
||||
struct NativeTextEditor_Previews: PreviewProvider{
|
||||
static var previews: some View {
|
||||
@FocusState var keyboardVisible: Bool
|
||||
return NativeTextEditor(
|
||||
text: Binding.constant("Hello, world!"),
|
||||
disableEditing: Binding.constant(false),
|
||||
height: 100,
|
||||
font: UIFont.preferredFont(forTextStyle: .body),
|
||||
focused: $keyboardVisible,
|
||||
focused: Binding.constant(false),
|
||||
alignment: TextAlignment.leading,
|
||||
onImagesAdded: { _ in }
|
||||
)
|
||||
|
||||
@@ -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,15 +23,19 @@ 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
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
@Binding var keyboardVisible: Bool
|
||||
@State private var teHeight: CGFloat = 42
|
||||
@State private var teFont: Font = .body
|
||||
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
|
||||
@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
|
||||
@@ -65,6 +69,7 @@ struct SendMessageView: View {
|
||||
|
||||
NativeTextEditor(
|
||||
text: $composeState.message,
|
||||
disableEditing: $composeState.inProgress,
|
||||
height: teHeight,
|
||||
font: teUiFont,
|
||||
focused: $keyboardVisible,
|
||||
@@ -83,7 +88,7 @@ struct SendMessageView: View {
|
||||
.padding([.bottom, .trailing], 3)
|
||||
} else {
|
||||
VStack(alignment: .trailing) {
|
||||
if teHeight > 100 {
|
||||
if teHeight > 100 && !composeState.inProgress {
|
||||
deleteTextButton()
|
||||
Spacer()
|
||||
}
|
||||
@@ -146,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 ||
|
||||
@@ -163,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +401,6 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
@State var composeStateNew = ComposeState()
|
||||
let ci = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
@State var composeStateEditing = ComposeState(editingItem: ci)
|
||||
@FocusState var keyboardVisible: Bool
|
||||
@State var sendEnabled: Bool = true
|
||||
|
||||
return Group {
|
||||
@@ -364,9 +409,9 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
Spacer(minLength: 0)
|
||||
SendMessageView(
|
||||
composeState: $composeStateNew,
|
||||
sendMessage: {},
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
keyboardVisible: Binding.constant(true)
|
||||
)
|
||||
}
|
||||
VStack {
|
||||
@@ -374,9 +419,9 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
Spacer(minLength: 0)
|
||||
SendMessageView(
|
||||
composeState: $composeStateEditing,
|
||||
sendMessage: {},
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
keyboardVisible: Binding.constant(true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +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)
|
||||
|
||||
Section {
|
||||
Button("Reset") { featuresAllowed = currentFeaturesAllowed }
|
||||
@@ -88,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) }
|
||||
@@ -128,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(
|
||||
|
||||
@@ -10,16 +10,27 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct AddGroupMembersView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var chat: Chat
|
||||
var groupInfo: GroupInfo
|
||||
|
||||
var body: some View {
|
||||
AddGroupMembersViewCommon(chat: chat, groupInfo: groupInfo, addedMembersCb: { _ in dismiss() })
|
||||
}
|
||||
}
|
||||
|
||||
struct AddGroupMembersViewCommon: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
var chat: Chat
|
||||
@State var groupInfo: GroupInfo
|
||||
var creatingGroup: Bool = false
|
||||
var showFooterCounter: Bool = true
|
||||
var addedMembersCb: ((Set<Int64>) -> Void)? = nil
|
||||
var addedMembersCb: ((Set<Int64>) -> Void)
|
||||
@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
|
||||
@@ -39,7 +50,7 @@ struct AddGroupMembersView: View {
|
||||
addGroupMembersView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Skip") { addedMembersCb?(selectedContacts) }
|
||||
Button ("Skip") { addedMembersCb(selectedContacts) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,7 +87,7 @@ struct AddGroupMembersView: 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")
|
||||
}
|
||||
@@ -88,7 +99,11 @@ struct AddGroupMembersView: 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)
|
||||
}
|
||||
}
|
||||
@@ -107,6 +122,9 @@ struct AddGroupMembersView: View {
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedContacts) { _ in
|
||||
searchFocussed = false
|
||||
}
|
||||
}
|
||||
|
||||
private func inviteMembersButton() -> some View {
|
||||
@@ -128,8 +146,7 @@ struct AddGroupMembersView: View {
|
||||
let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole)
|
||||
await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) }
|
||||
}
|
||||
await MainActor.run { dismiss() }
|
||||
if let cb = addedMembersCb { cb(selectedContacts) }
|
||||
addedMembersCb(selectedContacts)
|
||||
} catch {
|
||||
let a = getErrorAlert(error, "Error adding member(s)")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
@@ -191,6 +208,31 @@ struct AddGroupMembersView: 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
|
||||
@@ -45,6 +47,8 @@ struct GroupChatInfoView: View {
|
||||
Section {
|
||||
if groupInfo.canEdit {
|
||||
editGroupButton()
|
||||
}
|
||||
if groupInfo.groupProfile.description != nil || groupInfo.canEdit {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
groupPreferencesButton($groupInfo)
|
||||
@@ -65,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)
|
||||
@@ -116,6 +126,7 @@ struct GroupChatInfoView: View {
|
||||
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
.keyboardPadding()
|
||||
}
|
||||
|
||||
private func groupInfoHeader() -> some View {
|
||||
@@ -127,12 +138,14 @@ struct GroupChatInfoView: View {
|
||||
.padding()
|
||||
Text(cInfo.displayName)
|
||||
.font(.largeTitle)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
.padding(.bottom, 2)
|
||||
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName {
|
||||
Text(cInfo.fullName)
|
||||
.font(.title2)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -142,7 +155,13 @@ struct GroupChatInfoView: View {
|
||||
NavigationLink {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
.onAppear {
|
||||
ChatModel.shared.groupMembers = apiListMembersSync(groupInfo.groupId)
|
||||
searchFocussed = false
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
|
||||
@@ -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,28 @@ 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 syncConnectionForceAlert
|
||||
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 .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
case let .other(alert): return "other \(alert)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +54,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,14 +72,42 @@ 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)
|
||||
}
|
||||
}
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
QRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [contactLink])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} else {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +138,21 @@ 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 }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +171,13 @@ 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)
|
||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
member = mem
|
||||
connectionStats = stats
|
||||
@@ -122,6 +185,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 {
|
||||
@@ -135,7 +199,30 @@ 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 .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||
case let .connRequestSentAlert(type): return connReqSentAlert(type)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .other(alert): return alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,19 +261,30 @@ struct GroupMemberInfoView: View {
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
HStack {
|
||||
if mem.verified {
|
||||
Image(systemName: "checkmark.shield")
|
||||
}
|
||||
if mem.verified {
|
||||
(
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
.foregroundColor(.secondary)
|
||||
.font(.title2)
|
||||
+ Text(" ")
|
||||
+ Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.bottom, 2)
|
||||
} else {
|
||||
Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
if mem.fullName != "" && mem.fullName != mem.displayName {
|
||||
Text(mem.fullName)
|
||||
.font(.title2)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -220,7 +318,24 @@ struct GroupMemberInfoView: View {
|
||||
systemImage: member.verified ? "checkmark.shield" : "shield"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
syncMemberConnection(force: false)
|
||||
} label: {
|
||||
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeConnectionButtonForce() -> some View {
|
||||
Button {
|
||||
alert = .syncConnectionForceAlert
|
||||
} label: {
|
||||
Label("Renegotiate encryption", systemImage: "exclamationmark.triangle")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMemberButton(_ mem: GroupMember) -> some View {
|
||||
@@ -285,7 +400,12 @@ 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
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -295,6 +415,43 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func abortSwitchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error aborting address change")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncMemberConnection(force: Bool) {
|
||||
Task {
|
||||
do {
|
||||
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -15,43 +15,102 @@ struct GroupWelcomeView: View {
|
||||
var groupId: Int64
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@State private var welcomeText: String = ""
|
||||
@State private var editMode = true
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var showSaveDialog = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
TextEditor(text: $welcomeText)
|
||||
.focused($keyboardVisible)
|
||||
.padding(.horizontal, -5)
|
||||
.padding(.top, -8)
|
||||
.frame(height: 90, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
Section {
|
||||
saveButton()
|
||||
VStack {
|
||||
if groupInfo.canEdit {
|
||||
editorView()
|
||||
.modifier(BackButton {
|
||||
if welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil) {
|
||||
dismiss()
|
||||
} else {
|
||||
showSaveDialog = true
|
||||
}
|
||||
})
|
||||
.confirmationDialog("Save welcome message?", isPresented: $showSaveDialog) {
|
||||
Button("Save and update group profile") {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") { dismiss() }
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
textPreview()
|
||||
copyButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
welcomeText = groupInfo.groupProfile.description ?? ""
|
||||
}
|
||||
.modifier(BackButton {
|
||||
if welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil) {
|
||||
dismiss()
|
||||
} else {
|
||||
showSaveDialog = true
|
||||
}
|
||||
})
|
||||
.confirmationDialog("Save welcome message?", isPresented: $showSaveDialog) {
|
||||
Button("Save and update group profile") {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") { dismiss() }
|
||||
keyboardVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func saveButton() -> some 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 {
|
||||
List {
|
||||
Section {
|
||||
if editMode {
|
||||
ZStack {
|
||||
Group {
|
||||
if welcomeText.isEmpty {
|
||||
TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder")))
|
||||
.foregroundColor(.secondary)
|
||||
.disabled(true)
|
||||
}
|
||||
TextEditor(text: $welcomeText)
|
||||
.focused($keyboardVisible)
|
||||
}
|
||||
.padding(.horizontal, -5)
|
||||
.padding(.top, -8)
|
||||
.frame(height: 140, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
textPreview()
|
||||
}
|
||||
|
||||
Button {
|
||||
editMode = !editMode
|
||||
keyboardVisible = editMode
|
||||
} label: {
|
||||
if editMode {
|
||||
Label ("Preview", systemImage: "character")
|
||||
} else {
|
||||
Label ("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
.disabled(welcomeText.isEmpty)
|
||||
copyButton()
|
||||
}
|
||||
|
||||
Section {
|
||||
saveButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func copyButton() -> some View {
|
||||
Button {
|
||||
UIPasteboard.general.string = welcomeText
|
||||
} label: {
|
||||
Label ("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveButton() -> some View {
|
||||
Button("Save and update group profile") {
|
||||
save()
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
@@ -231,6 +263,7 @@ struct ChatListNavLink: View {
|
||||
.sheet(isPresented: $showContactConnectionInfo) {
|
||||
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
||||
ContactConnectionInfo(contactConnection: contactConnection)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
@@ -347,6 +380,7 @@ struct ChatListNavLink: View {
|
||||
.onTapGesture { showInvalidJSON = true }
|
||||
.sheet(isPresented: $showInvalidJSON) {
|
||||
invalidJSONView(json)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,21 @@ import SimpleXChat
|
||||
|
||||
struct ChatListView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@State private var showSettings = false
|
||||
@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 {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody.scrollDismissesKeyboard(.immediately)
|
||||
} else {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
|
||||
private var viewBody: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NavStackCompat(
|
||||
isActive: Binding(
|
||||
@@ -29,11 +38,7 @@ struct ChatListView: View {
|
||||
if chatModel.chats.isEmpty {
|
||||
onboardingButtons()
|
||||
}
|
||||
if chatModel.chats.count > 8 {
|
||||
chatList.searchable(text: $searchText)
|
||||
} else {
|
||||
chatList
|
||||
}
|
||||
chatListView
|
||||
}
|
||||
}
|
||||
if userPickerVisible {
|
||||
@@ -47,64 +52,74 @@ 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() }
|
||||
.onAppear() { connectViaUrl() }
|
||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
||||
.refreshable {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Reconnect servers?"),
|
||||
message: Text("Reconnect all connected servers to force message delivery. It uses additional traffic."),
|
||||
primaryButton: .default(Text("Ok")) {
|
||||
Task {
|
||||
do {
|
||||
try await reconnectAllServers()
|
||||
} catch let error {
|
||||
AlertManager.shared.showAlertMsg(title: "Error", message: "\(responseError(error))")
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
.offset(x: -8)
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Your chats")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
if chatModel.users.filter { u in u.user.activeUser || !u.user.hidden }.count > 1 {
|
||||
let user = chatModel.currentUser ?? User.sampleData
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel))
|
||||
.frame(width: 32, height: 32)
|
||||
.padding(.trailing, 4)
|
||||
let allRead = chatModel.users
|
||||
.filter { u in !u.user.activeUser && !u.user.hidden }
|
||||
.allSatisfy { u in u.unreadCount == 0 }
|
||||
if !allRead {
|
||||
unreadBadge(size: 12)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
} else {
|
||||
showSettings = true
|
||||
}
|
||||
} label: {
|
||||
let user = chatModel.currentUser ?? User.sampleData
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel))
|
||||
.frame(width: 32, height: 32)
|
||||
.padding(.trailing, 4)
|
||||
let allRead = chatModel.users
|
||||
.filter { u in !u.user.activeUser && !u.user.hidden }
|
||||
.allSatisfy { u in u.unreadCount == 0 }
|
||||
if !allRead {
|
||||
unreadBadge(size: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
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 {
|
||||
@@ -114,8 +129,36 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(showSettings: $showSettings)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,19 +221,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,9 +285,9 @@ struct ChatListView_Previews: PreviewProvider {
|
||||
|
||||
]
|
||||
return Group {
|
||||
ChatListView()
|
||||
ChatListView(showSettings: Binding.constant(false))
|
||||
.environmentObject(chatModel)
|
||||
ChatListView()
|
||||
ChatListView(showSettings: Binding.constant(false))
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,6 +39,11 @@ struct ContactConnectionInfo: View {
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Text(contactConnectionText(contactConnection))
|
||||
.padding(.bottom, 16)
|
||||
|
||||
if let connReqInv = contactConnection.connReqInv {
|
||||
OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInv)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -47,11 +52,7 @@ struct ContactConnectionInfo: View {
|
||||
|
||||
Section {
|
||||
if contactConnection.groupLinkId == nil {
|
||||
HStack(spacing: 20) {
|
||||
Image(systemName: "pencil")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.leading, 6)
|
||||
.onTapGesture { aliasTextFieldFocused = true }
|
||||
settingsRow("pencil") {
|
||||
TextField("Set contact name…", text: $localAlias)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled(true)
|
||||
@@ -59,20 +60,18 @@ struct ContactConnectionInfo: View {
|
||||
.submitLabel(.done)
|
||||
.onSubmit(setConnectionAlias)
|
||||
}
|
||||
.onTapGesture { aliasTextFieldFocused = true }
|
||||
}
|
||||
|
||||
if contactConnection.initiated,
|
||||
let connReqInv = contactConnection.connReqInv {
|
||||
NavigationLink {
|
||||
AddContactView(contactConnection: contactConnection, connReqInvitation: connReqInv)
|
||||
.navigationTitle(CreateLinkTab.oneTime.title)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Show QR code", systemImage: "qrcode")
|
||||
.foregroundColor(contactConnection.incognito ? .indigo : .accentColor)
|
||||
}
|
||||
oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInv)
|
||||
} else {
|
||||
oneTimeLinkLearnMoreButton()
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
alert = .deleteInvitationAlert
|
||||
} label: {
|
||||
@@ -81,7 +80,6 @@ struct ContactConnectionInfo: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
.alert(item: $alert) { _alert in
|
||||
switch _alert {
|
||||
@@ -96,7 +94,6 @@ struct ContactConnectionInfo: View {
|
||||
}
|
||||
.onAppear {
|
||||
localAlias = contactConnection.localAlias
|
||||
aliasTextFieldFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ struct DatabaseEncryptionView: View {
|
||||
@State private var progressIndicator = false
|
||||
@State private var useKeychainToggle = storeDBPassphraseGroupDefault.get()
|
||||
@State private var initialRandomDBPassphrase = initialRandomDBPassphraseGroupDefault.get()
|
||||
@State private var storedKey = getDatabaseKey() != nil
|
||||
@State private var storedKey = kcDatabasePassword.get() != nil
|
||||
@State private var currentKey = ""
|
||||
@State private var newKey = ""
|
||||
@State private var confirmNewKey = ""
|
||||
@@ -124,7 +124,7 @@ struct DatabaseEncryptionView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if initialRandomDBPassphrase { currentKey = getDatabaseKey() ?? "" }
|
||||
if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" }
|
||||
}
|
||||
.disabled(m.chatRunning != false)
|
||||
.alert(item: $alert) { item in databaseEncryptionAlert(item) }
|
||||
@@ -140,7 +140,7 @@ struct DatabaseEncryptionView: View {
|
||||
encryptionStartedDefault.set(false)
|
||||
initialRandomDBPassphraseGroupDefault.set(false)
|
||||
if useKeychain {
|
||||
if setDatabaseKey(newKey) {
|
||||
if kcDatabasePassword.set(newKey) {
|
||||
await resetFormAfterEncryption(true)
|
||||
await operationEnded(.databaseEncrypted)
|
||||
} else {
|
||||
@@ -184,7 +184,7 @@ struct DatabaseEncryptionView: View {
|
||||
title: Text("Remove passphrase from keychain?"),
|
||||
message: Text("Instant push notifications will be hidden!\n") + storeSecurelyDanger(),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
if removeDatabaseKey() {
|
||||
if kcDatabasePassword.remove() {
|
||||
logger.debug("passphrase removed from keychain")
|
||||
setUseKeychain(false)
|
||||
storedKey = false
|
||||
|
||||
@@ -13,7 +13,7 @@ struct DatabaseErrorView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State var status: DBMigrationResult
|
||||
@State private var dbKey = ""
|
||||
@State private var storedDBKey = getDatabaseKey()
|
||||
@State private var storedDBKey = kcDatabasePassword.get()
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State private var showRestoreDbButton = false
|
||||
@State private var starting = false
|
||||
@@ -131,7 +131,7 @@ struct DatabaseErrorView: View {
|
||||
}
|
||||
|
||||
private func saveAndRunChat() {
|
||||
if setDatabaseKey(dbKey) {
|
||||
if kcDatabasePassword.set(dbKey) {
|
||||
storeDBPassphraseGroupDefault.set(true)
|
||||
initialRandomDBPassphraseGroupDefault.set(false)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -77,7 +79,7 @@ struct DatabaseView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
.disabled(m.chatDbChanged || progressIndicator)
|
||||
.disabled(stopped || progressIndicator)
|
||||
} header: {
|
||||
Text("Messages")
|
||||
} footer: {
|
||||
@@ -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)
|
||||
_ = removeDatabaseKey()
|
||||
await operationEnded(.archiveImported)
|
||||
let archiveErrors = try await apiImportArchive(config: config)
|
||||
_ = kcDatabasePassword.remove()
|
||||
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()
|
||||
_ = removeDatabaseKey()
|
||||
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)
|
||||
|
||||
@@ -65,7 +65,7 @@ struct MigrateToAppGroupView: View {
|
||||
case .exporting:
|
||||
center {
|
||||
ProgressView(value: 0.33)
|
||||
Text("Exporting database archive...")
|
||||
Text("Exporting database archive…")
|
||||
}
|
||||
migrationProgress()
|
||||
case .export_error:
|
||||
@@ -82,7 +82,7 @@ struct MigrateToAppGroupView: View {
|
||||
case .migrating:
|
||||
center {
|
||||
ProgressView(value: 0.67)
|
||||
Text("Migrating database archive...")
|
||||
Text("Migrating database archive…")
|
||||
}
|
||||
migrationProgress()
|
||||
case .migration_error:
|
||||
@@ -109,7 +109,8 @@ struct MigrateToAppGroupView: View {
|
||||
do {
|
||||
resetChatCtrl()
|
||||
try initializeChat(start: true)
|
||||
chatModel.onboardingStage = .step3_SetNotificationsMode
|
||||
onboardingStageDefault.set(.step4_SetNotificationsMode)
|
||||
chatModel.onboardingStage = .step4_SetNotificationsMode
|
||||
setV3DBMigration(.ready)
|
||||
} catch let error {
|
||||
dbContainerGroupDefault.set(.documents)
|
||||
@@ -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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user