Compare commits
351 Commits
v4.6.1-bet
...
ep/rfc-use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0366afbcce | ||
|
|
4c33ed92bb | ||
|
|
a0ae4125c5 | ||
|
|
34a60066fb | ||
|
|
1f50e94bc9 | ||
|
|
0e4376bada | ||
|
|
b0ad94fe7f | ||
|
|
fccd4f7ec4 | ||
|
|
3f93397031 | ||
|
|
a5f8641d50 | ||
|
|
f23c0b55f8 | ||
|
|
534151f1bb | ||
|
|
2ad9d0ddbc | ||
|
|
388bdc7083 | ||
|
|
3e370a7c16 | ||
|
|
77b3870654 | ||
|
|
b088b1c44c | ||
|
|
8abad4f711 | ||
|
|
4c668f7a34 | ||
|
|
0bf5fbd641 | ||
|
|
cfec60bf86 | ||
|
|
9caaab0e8e | ||
|
|
6da18d9b2a | ||
|
|
da2622f00e | ||
|
|
7ed581dfbf | ||
|
|
78c0fe73a7 | ||
|
|
15b00f6110 | ||
|
|
f592a26b00 | ||
|
|
30687f5fa6 | ||
|
|
bc7217d686 | ||
|
|
8e0b3fa32e | ||
|
|
d929c34e71 | ||
|
|
ec7bff9205 | ||
|
|
22f20a9c5f | ||
|
|
ddf81d28f1 | ||
|
|
5c105cb746 | ||
|
|
e1370e8f3c | ||
|
|
9fbcc2b5bb | ||
|
|
53d77b25ed | ||
|
|
e7089d4c2f | ||
|
|
6d3cb0ea2e | ||
|
|
46c6f5e615 | ||
|
|
c29c3179a0 | ||
|
|
3e84429a3a | ||
|
|
904b6db628 | ||
|
|
af4e94058a | ||
|
|
91b77b6d63 | ||
|
|
5a0c7c34bf | ||
|
|
3267b4d6ca | ||
|
|
9b302b856a | ||
|
|
4e696aed82 | ||
|
|
425c7b947f | ||
|
|
d4f9429fc1 | ||
|
|
161b43e85d | ||
|
|
d585e8f5a7 | ||
|
|
060e7cdf52 | ||
|
|
6fa002948e | ||
|
|
bbd4e6c8ba | ||
|
|
92cf945e10 | ||
|
|
cc0f55c245 | ||
|
|
22f27c4255 | ||
|
|
14a888bf43 | ||
|
|
f6fddc9436 | ||
|
|
f581e91f19 | ||
|
|
fb72dfcdee | ||
|
|
925813b14c | ||
|
|
abd410fe62 | ||
|
|
875282e9ec | ||
|
|
6afda28367 | ||
|
|
0721b24250 | ||
|
|
10b6bce8a2 | ||
|
|
0101444c5d | ||
|
|
128883b8a3 | ||
|
|
cc75b75d4e | ||
|
|
dea6cd81c7 | ||
|
|
2f53ab08b5 | ||
|
|
d7f3d1f19d | ||
|
|
a4517fcb9b | ||
|
|
4a12cf0922 | ||
|
|
0ee91b0280 | ||
|
|
e131890f54 | ||
|
|
bd069aea49 | ||
|
|
42d4f94fec | ||
|
|
3af2848275 | ||
|
|
8b1e5d3db7 | ||
|
|
57ed903a48 | ||
|
|
6093219ce9 | ||
|
|
f9f34911b1 | ||
|
|
494328541a | ||
|
|
fd2c7c888c | ||
|
|
24c09f2041 | ||
|
|
a1e6d90e31 | ||
|
|
de33fedea4 | ||
|
|
9f89104f94 | ||
|
|
f0e88220c6 | ||
|
|
6d7e16d6e1 | ||
|
|
d7d38fddb8 | ||
|
|
527a5bc6b5 | ||
|
|
9644dcb9b4 | ||
|
|
f4861482f1 | ||
|
|
dc73bb3caf | ||
|
|
bcbfc1758e | ||
|
|
e65dcf51b0 | ||
|
|
1326701440 | ||
|
|
c32e45f686 | ||
|
|
0160684004 | ||
|
|
734b920fde | ||
|
|
174e703b4c | ||
|
|
36336a3a57 | ||
|
|
c10a4346a9 | ||
|
|
db55496fc7 | ||
|
|
9a2efd0ef0 | ||
|
|
579af09816 | ||
|
|
d39614713d | ||
|
|
34af1e258c | ||
|
|
3ff68dbc7b | ||
|
|
8952ac9af0 | ||
|
|
7799a1e260 | ||
|
|
353927e6d2 | ||
|
|
d40db1ddea | ||
|
|
f85a9e174c | ||
|
|
838e14af60 | ||
|
|
1d84c5cad8 | ||
|
|
3be2259068 | ||
|
|
acc4cad082 | ||
|
|
10a1788754 | ||
|
|
f1c1059ff8 | ||
|
|
690c8ea2c9 | ||
|
|
1c8d1bc9ff | ||
|
|
a9416d89e3 | ||
|
|
9e33ba46af | ||
|
|
a0c4726af3 | ||
|
|
a0b3c0a5a4 | ||
|
|
9978957e6c | ||
|
|
f155611d29 | ||
|
|
01b3e98358 | ||
|
|
3a50da1b53 | ||
|
|
a32fd5e665 | ||
|
|
b6a4f5f518 | ||
|
|
e799e80843 | ||
|
|
63cb7a75b3 | ||
|
|
922e95756a | ||
|
|
b49f0d211b | ||
|
|
761fbf7757 | ||
|
|
4ee052e71e | ||
|
|
0274f3c2ac | ||
|
|
ae13f1aa23 | ||
|
|
904405ebee | ||
|
|
a059739210 | ||
|
|
25156bb56c | ||
|
|
d62761b3a8 | ||
|
|
817c0a5672 | ||
|
|
c06a970987 | ||
|
|
baf3a12009 | ||
|
|
0ec2468dce | ||
|
|
0cfc9fd1fa | ||
|
|
a2de9a3846 | ||
|
|
88059a2cc5 | ||
|
|
635d797b2e | ||
|
|
e635c45ec6 | ||
|
|
594ae61192 | ||
|
|
2945f688fa | ||
|
|
d86cca2e26 | ||
|
|
fca315ee1f | ||
|
|
a12f140333 | ||
|
|
ad7e4488ef | ||
|
|
df4e954f8a | ||
|
|
63f344bde6 | ||
|
|
0b8d9d11e2 | ||
|
|
57801fde1f | ||
|
|
c87f4e68f7 | ||
|
|
27762492d7 | ||
|
|
da7c408686 | ||
|
|
0c0a98605d | ||
|
|
108226bcdc | ||
|
|
8b400d4f2c | ||
|
|
d838e7b44d | ||
|
|
7b157fa8e5 | ||
|
|
d19a59a364 | ||
|
|
b95a351222 | ||
|
|
1038acd2ea | ||
|
|
54fc052e47 | ||
|
|
62bac800af | ||
|
|
aa2b36d5cc | ||
|
|
b5f482bb50 | ||
|
|
649c104d29 | ||
|
|
41368c85bf | ||
|
|
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 | ||
|
|
ccb52e0acd | ||
|
|
d84b30c071 | ||
|
|
5ae0afe1fe | ||
|
|
d250e503b0 | ||
|
|
afb0ae3d03 | ||
|
|
1a3f0bed47 | ||
|
|
1e280fb7e1 | ||
|
|
6feac55380 | ||
|
|
93d8eac037 | ||
|
|
a11f99be3d | ||
|
|
da17639309 | ||
|
|
10301aa742 | ||
|
|
2148d50393 | ||
|
|
12fb2a4ec5 | ||
|
|
8085e5b85c | ||
|
|
4ba310ec16 | ||
|
|
865c56f400 | ||
|
|
c510e73256 | ||
|
|
73638129bc | ||
|
|
1a7a79d504 | ||
|
|
d3268e4a72 | ||
|
|
15a93014a5 | ||
|
|
e7735329bc | ||
|
|
3e222c68eb | ||
|
|
a596bd9011 | ||
|
|
21a49710a8 | ||
|
|
ce6fdb2558 | ||
|
|
0baee848a6 | ||
|
|
6f304bc9e6 | ||
|
|
1ca0dfffa0 | ||
|
|
1420084f5e | ||
|
|
3e03474437 | ||
|
|
95366e4d1b | ||
|
|
df1775a1e6 | ||
|
|
30ccea18ab | ||
|
|
4cd90d74ad | ||
|
|
7f1214688a | ||
|
|
aa89d0d156 | ||
|
|
787cd94362 | ||
|
|
ec61a7fc51 | ||
|
|
9b627534f5 | ||
|
|
400a3707b2 |
68
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
68
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -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
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
40
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -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
|
||||
16
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
16
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -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
|
||||
36
.github/workflows/build.yml
vendored
36
.github/workflows/build.yml
vendored
@@ -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
|
||||
@@ -52,9 +52,9 @@ jobs:
|
||||
- os: ubuntu-20.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||
- os: ubuntu-18.04
|
||||
- os: ubuntu-22.04
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-ubuntu-18_04-x86-64
|
||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||
- os: macos-latest
|
||||
cache_path: ~/.cabal/store
|
||||
asset_name: simplex-chat-macos-x86-64
|
||||
@@ -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 }}
|
||||
@@ -96,7 +104,7 @@ jobs:
|
||||
run: brew install pkg-config
|
||||
|
||||
- name: Unix prepare cabal.project.local for Ubuntu
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-18.04'
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
@@ -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' && matrix.os != 'ubuntu-20.04'
|
||||
timeout-minutes: 20
|
||||
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
|
||||
|
||||
2
.github/workflows/web.yml
vendored
2
.github/workflows/web.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [16.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
82
README.md
82
README.md
@@ -4,7 +4,7 @@
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
[](https://mastodon.social/@simplex)
|
||||
|
||||
| 19/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||
|
||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -79,21 +102,26 @@ Join our translators to help SimpleX grow!
|
||||
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
||||
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
||||
|🇬🇧 en|English | |✓|✓|✓|✓|
|
||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
||||
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
||||
|🇪🇸 es|Español ||[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/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)|
|
||||
|🇪🇸 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/)|||
|
||||
|🇨🇳 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.
|
||||
@@ -179,6 +207,10 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent updates:
|
||||
|
||||
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
|
||||
|
||||
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||
|
||||
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||
|
||||
[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).
|
||||
@@ -225,13 +257,15 @@ See [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md) for the format of m
|
||||
|
||||
SimpleX Chat is a work in progress – we are releasing improvements as they are ready. You have to decide if the current state is good enough for your usage scenario.
|
||||
|
||||
We compiled a [glossary of terms](./docs/GLOSSARY.md) used to describe communication systems to help understand some terms below and to help compare advantages and disadvantages of various communication systems.
|
||||
|
||||
What is already implemented:
|
||||
|
||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||
2. End-to-end encryption in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
||||
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with forward secrecy (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||
2. [End-to-end encryption](./docs/GLOSSARY.md#end-to-end-encryption) in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
||||
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||
5. Several levels of content padding to frustrate message size attacks.
|
||||
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
|
||||
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
||||
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||
@@ -256,6 +290,8 @@ You can:
|
||||
|
||||
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
||||
|
||||
Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) group to ask any questions and share your success stories.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
|
||||
@@ -293,26 +329,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.
|
||||
- 🏗 Desktop client.
|
||||
- 🏗 Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||
- SMP queue redundancy and rotation (manual is supported).
|
||||
- Include optional message into connection request sent via contact address.
|
||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- 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.
|
||||
|
||||
|
||||
20
apps/android/.gitignore
vendored
20
apps/android/.gitignore
vendored
@@ -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/
|
||||
1
apps/android/.idea/.name
generated
1
apps/android/.idea/.name
generated
@@ -1 +0,0 @@
|
||||
SimpleX
|
||||
6
apps/android/.idea/compiler.xml
generated
6
apps/android/.idea/compiler.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
</component>
|
||||
</project>
|
||||
19
apps/android/.idea/gradle.xml
generated
19
apps/android/.idea/gradle.xml
generated
@@ -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>
|
||||
6
apps/android/.idea/vcs.xml
generated
6
apps/android/.idea/vcs.xml
generated
@@ -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,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,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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,447 +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.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 -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||
is MsgContent.MCVoice -> 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,153 +0,0 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import coil.ImageLoader
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import com.google.accompanist.pager.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
interface ImageGalleryProvider {
|
||||
val initialIndex: Int
|
||||
val totalImagesSize: MutableState<Int>
|
||||
fun getImage(index: Int): Pair<Bitmap, Uri>?
|
||||
fun currentPageChanged(index: Int)
|
||||
fun scrollToStart()
|
||||
fun onDismiss(index: Int)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) {
|
||||
val provider = remember { imageProvider() }
|
||||
val pagerState = rememberPagerState(provider.initialIndex)
|
||||
val goBack = { provider.onDismiss(pagerState.currentPage); close() }
|
||||
BackHandler(onBack = goBack)
|
||||
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
|
||||
// which makes this blank page visible for a moment. Prevent it by doing the check ourselves
|
||||
LaunchedEffect(Unit) {
|
||||
if (provider.getImage(provider.initialIndex - 1) == null) {
|
||||
provider.scrollToStart()
|
||||
pagerState.scrollToPage(0)
|
||||
}
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
HorizontalPager(count = remember { provider.totalImagesSize }.value, state = pagerState) { index ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = goBack)
|
||||
) {
|
||||
var settledCurrentPage by remember { mutableStateOf(pagerState.currentPage) }
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow {
|
||||
if (!pagerState.isScrollInProgress) pagerState.currentPage else settledCurrentPage
|
||||
}.collect {
|
||||
settledCurrentPage = it
|
||||
}
|
||||
}
|
||||
LaunchedEffect(settledCurrentPage) {
|
||||
// Make this pager with infinity scrolling with only 3 pages at a time when left and right pages constructs in real time
|
||||
if (settledCurrentPage != provider.initialIndex)
|
||||
provider.currentPageChanged(index)
|
||||
}
|
||||
val image = provider.getImage(index)
|
||||
if (image == null) {
|
||||
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
|
||||
SideEffect {
|
||||
scope.launch {
|
||||
when (settledCurrentPage) {
|
||||
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
|
||||
index + 1 -> {
|
||||
provider.scrollToStart()
|
||||
pagerState.scrollToPage(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val (imageBitmap: Bitmap, uri: Uri) = image
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var translationX by remember { mutableStateOf(0f) }
|
||||
var translationY by remember { mutableStateOf(0f) }
|
||||
var viewWidth by remember { mutableStateOf(0) }
|
||||
var allowTranslate by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(settledCurrentPage) {
|
||||
scale = 1f
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
Image(
|
||||
rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||
imageLoader = imageLoader
|
||||
),
|
||||
contentDescription = stringResource(R.string.image_descr),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
viewWidth = it.size.width
|
||||
}
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = translationX,
|
||||
translationY = translationY,
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures(
|
||||
{ allowTranslate },
|
||||
onGesture = { _, pan, gestureZoom, _ ->
|
||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||
allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0
|
||||
if (scale > 1 && allowTranslate) {
|
||||
translationX += pan.x * scale
|
||||
translationY += pan.y * scale
|
||||
} else if (allowTranslate) {
|
||||
translationX = 0f
|
||||
translationY = 0f
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +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 PickFile: AttachmentOption()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseAttachmentView(
|
||||
attachmentOption: MutableState<AttachmentOption?>,
|
||||
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()
|
||||
}
|
||||
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 = {})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,29 +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>
|
||||
</resources>
|
||||
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></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>
|
||||
@@ -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
|
||||
@@ -132,6 +167,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func initAuthenticate() {
|
||||
logger.debug("initAuthenticate")
|
||||
if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil {
|
||||
userAuthorized = false
|
||||
} else if doAuthenticate {
|
||||
@@ -152,14 +188,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("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 +225,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,11 +36,13 @@ 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
|
||||
]
|
||||
audioRecorder = try AVAudioRecorder(url: getAppFilePath(fileName), settings: settings)
|
||||
let url = getAppFilePath(fileName)
|
||||
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
audioRecorder?.record(forDuration: MAX_VOICE_MESSAGE_LENGTH)
|
||||
|
||||
await MainActor.run {
|
||||
@@ -101,10 +103,14 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
self.onFinishPlayback = onFinishPlayback
|
||||
}
|
||||
|
||||
func start(fileName: String) {
|
||||
audioPlayer = try? AVAudioPlayer(contentsOf: getAppFilePath(fileName))
|
||||
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
|
||||
@@ -123,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()
|
||||
|
||||
@@ -21,6 +21,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
|
||||
@@ -33,8 +34,6 @@ final class ChatModel: ObservableObject {
|
||||
// items in the terminal view
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@Published var userAddress: UserContactLink?
|
||||
@Published var userSMPServers: [ServerCfg]?
|
||||
@Published var presetSMPServers: [String]?
|
||||
@Published var chatItemTTL: ChatItemTTL = .none
|
||||
@Published var appOpenUrl: URL?
|
||||
@Published var deviceToken: DeviceToken?
|
||||
@@ -55,13 +54,13 @@ final class ChatModel: ObservableObject {
|
||||
// currently showing QR code
|
||||
@Published var connReqInv: String?
|
||||
// audio recording and playback
|
||||
@Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
|
||||
var filesToDelete: [String] = []
|
||||
var filesToDelete: Set<URL> = []
|
||||
|
||||
static let shared = ChatModel()
|
||||
|
||||
@@ -239,16 +238,9 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
let ci = reversedChatItems[i]
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
withAnimation {
|
||||
self.reversedChatItems[i] = cItem
|
||||
self.reversedChatItems[i].viewTimestamp = .now
|
||||
// on some occasions the confirmation of message being accepted by the server (tick)
|
||||
// arrives earlier than the response from API, and item remains without tick
|
||||
if case .sndNew = cItem.meta.itemStatus {
|
||||
self.reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
|
||||
}
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
@@ -265,7 +257,30 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
withAnimation {
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
||||
let ci = reversedChatItems[i]
|
||||
reversedChatItems[i] = cItem
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
// on some occasions the confirmation of message being accepted by the server (tick)
|
||||
// arrives earlier than the response from API, and item remains without tick
|
||||
if case .sndNew = cItem.meta.itemStatus {
|
||||
reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
|
||||
}
|
||||
}
|
||||
|
||||
private func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
||||
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
||||
}
|
||||
|
||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if cItem.isRcvNew {
|
||||
decreaseUnreadCounter(cInfo)
|
||||
@@ -278,7 +293,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// remove from current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
_ = withAnimation {
|
||||
self.reversedChatItems.remove(at: i)
|
||||
}
|
||||
@@ -358,7 +373,7 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
|
||||
if let cItem = aboveItem {
|
||||
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
markCurrentChatRead(fromIndex: i)
|
||||
_updateChat(cInfo.id) { chat in
|
||||
var unreadBelow = 0
|
||||
@@ -406,7 +421,7 @@ final class ChatModel: ObservableObject {
|
||||
// update preview
|
||||
decreaseUnreadCounter(cInfo)
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
markChatItemRead_(i)
|
||||
}
|
||||
}
|
||||
@@ -451,7 +466,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == ci.id }), i < reversedChatItems.count - 1 {
|
||||
if let i = getChatItemIndex(ci), i < reversedChatItems.count - 1 {
|
||||
return reversedChatItems[i + 1]
|
||||
} else {
|
||||
return nil
|
||||
@@ -568,6 +583,14 @@ final class Chat: ObservableObject, Identifiable {
|
||||
self.chatStats = chatStats
|
||||
}
|
||||
|
||||
func copy(chatInfo: ChatInfo? = nil, chatItems: [ChatItem]? = nil, chatStats: ChatStats? = nil) -> Chat {
|
||||
Chat(
|
||||
chatInfo: chatInfo ?? self.chatInfo,
|
||||
chatItems: chatItems ?? self.chatItems,
|
||||
chatStats: chatStats ?? self.chatStats
|
||||
)
|
||||
}
|
||||
|
||||
var userCanSend: Bool {
|
||||
switch chatInfo {
|
||||
case .direct: return true
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
func getLoadedFilePath(_ file: CIFile?) -> String? {
|
||||
if let fileName = getLoadedFileName(file) {
|
||||
@@ -42,6 +43,17 @@ func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLoadedVideo(_ file: CIFile?) -> URL? {
|
||||
let loadedFilePath = getLoadedFilePath(file)
|
||||
if loadedFilePath != nil, let fileName = file?.filePath {
|
||||
let filePath = getAppFilePath(fileName)
|
||||
if FileManager.default.fileExists(atPath: filePath.path) {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAnimImage(_ image: UIImage) -> String? {
|
||||
let fileName = generateNewFileName("IMG", "gif")
|
||||
guard let imageData = image.imageData else { return nil }
|
||||
@@ -164,6 +176,20 @@ func saveFileFromURL(_ url: URL) -> String? {
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func saveFileFromURLWithoutLoad(_ url: URL) -> String? {
|
||||
let savedFile: String?
|
||||
do {
|
||||
let fileName = uniqueCombine(url.lastPathComponent)
|
||||
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
|
||||
ChatModel.shared.filesToDelete.remove(url)
|
||||
savedFile = fileName
|
||||
} catch {
|
||||
logger.error("FileUtils.saveFileFromURLWithoutLoad error: \(error.localizedDescription)")
|
||||
savedFile = nil
|
||||
}
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
|
||||
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
|
||||
}
|
||||
@@ -204,6 +230,18 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String {
|
||||
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
|
||||
}
|
||||
|
||||
extension AVAsset {
|
||||
func generatePreview() -> (UIImage, Int)? {
|
||||
let generator = AVAssetImageGenerator(asset: self)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
var actualTime = CMTimeMake(value: 0, timescale: 0)
|
||||
if let image = try? generator.copyCGImage(at: CMTimeMakeWithSeconds(0.0, preferredTimescale: 1), actualTime: &actualTime) {
|
||||
return (UIImage(cgImage: image), Int(duration.seconds))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
|
||||
if let cgImage = cgImage {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -187,7 +188,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 +244,10 @@ func apiExportArchive(config: ArchiveConfig) async throws {
|
||||
try await sendCommandOkResp(.apiExportArchive(config: config))
|
||||
}
|
||||
|
||||
func apiImportArchive(config: ArchiveConfig) async throws {
|
||||
try await sendCommandOkResp(.apiImportArchive(config: config))
|
||||
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
||||
let r = await chatSendCmd(.apiImportArchive(config: config))
|
||||
if case let .archiveImported(archiveErrors) = r { return archiveErrors }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteStorage() async throws {
|
||||
@@ -295,9 +298,15 @@ func loadChat(chat: Chat, search: String = "") {
|
||||
}
|
||||
}
|
||||
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? {
|
||||
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
|
||||
let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId))
|
||||
if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
|
||||
let chatModel = ChatModel.shared
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live)
|
||||
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
|
||||
let r: ChatResponse
|
||||
if type == .direct {
|
||||
var cItem: ChatItem!
|
||||
@@ -308,7 +317,9 @@ func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int6
|
||||
chatModel.messageDelivery[cItem.id] = endTask
|
||||
return cItem
|
||||
}
|
||||
if !networkErrorAlert(r) {
|
||||
if let networkErrorAlert = networkErrorAlert(r) {
|
||||
AlertManager.shared.showAlert(networkErrorAlert)
|
||||
} else {
|
||||
sendMessageErrorAlert(r)
|
||||
}
|
||||
endTask()
|
||||
@@ -337,6 +348,12 @@ func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
|
||||
let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
|
||||
if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
|
||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
|
||||
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
||||
@@ -391,30 +408,22 @@ func apiDeleteToken(token: DeviceToken) async throws {
|
||||
try await sendCommandOkResp(.apiDeleteToken(token: token))
|
||||
}
|
||||
|
||||
func getUserSMPServers() throws -> ([ServerCfg], [String]) {
|
||||
let userId = try currentUserId("getUserSMPServers")
|
||||
return try userSMPServersResponse(chatSendCmdSync(.apiGetUserSMPServers(userId: userId)))
|
||||
}
|
||||
|
||||
func getUserSMPServersAsync() async throws -> ([ServerCfg], [String]) {
|
||||
let userId = try currentUserId("getUserSMPServersAsync")
|
||||
return try userSMPServersResponse(await chatSendCmd(.apiGetUserSMPServers(userId: userId)))
|
||||
}
|
||||
|
||||
private func userSMPServersResponse(_ r: ChatResponse) throws -> ([ServerCfg], [String]) {
|
||||
if case let .userSMPServers(_, smpServers, presetServers) = r { return (smpServers, presetServers) }
|
||||
func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers {
|
||||
let userId = try currentUserId("getUserProtoServers")
|
||||
let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol))
|
||||
if case let .userProtoServers(_, servers) = r { return servers }
|
||||
throw r
|
||||
}
|
||||
|
||||
func setUserSMPServers(smpServers: [ServerCfg]) async throws {
|
||||
let userId = try currentUserId("setUserSMPServers")
|
||||
try await sendCommandOkResp(.apiSetUserSMPServers(userId: userId, smpServers: smpServers))
|
||||
func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws {
|
||||
let userId = try currentUserId("setUserProtoServers")
|
||||
try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers))
|
||||
}
|
||||
|
||||
func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> {
|
||||
let userId = try currentUserId("testSMPServer")
|
||||
let r = await chatSendCmd(.apiTestSMPServer(userId: userId, smpServer: smpServer))
|
||||
if case let .smpTestResult(_, testFailure) = r {
|
||||
func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
|
||||
let userId = try currentUserId("testProtoServer")
|
||||
let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
|
||||
if case let .serverTestResult(_, _, testFailure) = r {
|
||||
if let t = testFailure {
|
||||
return .failure(t)
|
||||
}
|
||||
@@ -471,12 +480,28 @@ func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (Con
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSwitchContact(contactId: Int64) async throws {
|
||||
try await sendCommandOkResp(.apiSwitchContact(contactId: contactId))
|
||||
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
|
||||
let r = chatSendCmdSync(.apiSwitchContact(contactId: contactId))
|
||||
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws {
|
||||
try await sendCommandOkResp(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
|
||||
func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats {
|
||||
let r = chatSendCmdSync(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberSwitchStarted(_, _, _, connectionStats) = r { return connectionStats }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiAbortSwitchContact(_ contactId: Int64) throws -> ConnectionStats {
|
||||
let r = chatSendCmdSync(.apiAbortSwitchContact(contactId: contactId))
|
||||
if case let .contactSwitchAborted(_, _, connectionStats) = r { return connectionStats }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiAbortSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats {
|
||||
let r = chatSendCmdSync(.apiAbortSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
|
||||
if case let .groupMemberSwitchAborted(_, _, _, connectionStats) = r { return connectionStats }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
|
||||
@@ -512,59 +537,70 @@ func apiAddContact() async -> String? {
|
||||
}
|
||||
let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false)
|
||||
if case let .invitation(_, connReqInvitation) = r { return connReqInvitation }
|
||||
connectionErrorAlert(r)
|
||||
AlertManager.shared.showAlert(connectionErrorAlert(r))
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiConnect(connReq: String) async -> ConnReqType? {
|
||||
let (connReqType, alert) = await apiConnect_(connReq: connReq)
|
||||
if let alert = alert {
|
||||
AlertManager.shared.showAlert(alert)
|
||||
return nil
|
||||
} else {
|
||||
return connReqType
|
||||
}
|
||||
}
|
||||
|
||||
func apiConnect_(connReq: String) async -> (ConnReqType?, Alert?) {
|
||||
guard let userId = ChatModel.shared.currentUser?.userId else {
|
||||
logger.error("apiConnect: no current user")
|
||||
return nil
|
||||
return (nil, nil)
|
||||
}
|
||||
let r = await chatSendCmd(.apiConnect(userId: userId, connReq: connReq))
|
||||
let am = AlertManager.shared
|
||||
switch r {
|
||||
case .sentConfirmation: return .invitation
|
||||
case .sentInvitation: return .contact
|
||||
case .sentConfirmation: return (.invitation, nil)
|
||||
case .sentInvitation: return (.contact, nil)
|
||||
case let .contactAlreadyExists(_, contact):
|
||||
let m = ChatModel.shared
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
await MainActor.run { m.chatId = c.id }
|
||||
}
|
||||
am.showAlertMsg(
|
||||
let alert = mkAlert(
|
||||
title: "Contact already exists",
|
||||
message: "You are already connected to \(contact.displayName)."
|
||||
)
|
||||
return nil
|
||||
return (nil, alert)
|
||||
case .chatCmdError(_, .error(.invalidConnReq)):
|
||||
am.showAlertMsg(
|
||||
let alert = mkAlert(
|
||||
title: "Invalid connection link",
|
||||
message: "Please check that you used the correct link or ask your contact to send you another one."
|
||||
)
|
||||
return nil
|
||||
return (nil, alert)
|
||||
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
|
||||
am.showAlertMsg(
|
||||
let alert = mkAlert(
|
||||
title: "Connection error (AUTH)",
|
||||
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
|
||||
)
|
||||
return nil
|
||||
return (nil, alert)
|
||||
case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))):
|
||||
if internalErr == "SEUniqueID" {
|
||||
am.showAlertMsg(
|
||||
let alert = mkAlert(
|
||||
title: "Already connected?",
|
||||
message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(responseError(r)))."
|
||||
)
|
||||
return nil
|
||||
return (nil, alert)
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
connectionErrorAlert(r)
|
||||
return nil
|
||||
let alert = connectionErrorAlert(r)
|
||||
return (nil, alert)
|
||||
}
|
||||
|
||||
private func connectionErrorAlert(_ r: ChatResponse) {
|
||||
if !networkErrorAlert(r) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
|
||||
if let networkErrorAlert = networkErrorAlert(r) {
|
||||
return networkErrorAlert
|
||||
} else {
|
||||
return mkAlert(
|
||||
title: "Connection error",
|
||||
message: "Error: \(String(describing: r))"
|
||||
)
|
||||
@@ -626,6 +662,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 }
|
||||
@@ -651,10 +697,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
|
||||
}
|
||||
|
||||
@@ -696,7 +742,9 @@ func apiAcceptContactRequest(contactReqId: Int64) async -> Contact? {
|
||||
title: "Connection error (AUTH)",
|
||||
message: "Sender may have deleted the connection request."
|
||||
)
|
||||
} else if !networkErrorAlert(r) {
|
||||
} else if let networkErrorAlert = networkErrorAlert(r) {
|
||||
am.showAlert(networkErrorAlert)
|
||||
} else {
|
||||
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
|
||||
am.showAlertMsg(
|
||||
title: "Error accepting contact request",
|
||||
@@ -735,7 +783,9 @@ func apiReceiveFile(fileId: Int64, inline: Bool? = nil) async -> AChatItem? {
|
||||
title: "Cannot receive file",
|
||||
message: "Sender cancelled file transfer."
|
||||
)
|
||||
} else if !networkErrorAlert(r) {
|
||||
} else if let networkErrorAlert = networkErrorAlert(r) {
|
||||
am.showAlert(networkErrorAlert)
|
||||
} else {
|
||||
logger.error("apiReceiveFile error: \(String(describing: r))")
|
||||
switch r {
|
||||
case .chatCmdError(_, .error(.fileAlreadyReceiving)):
|
||||
@@ -753,6 +803,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,23 +818,20 @@ func apiCancelFile(fileId: Int64) async -> AChatItem? {
|
||||
}
|
||||
}
|
||||
|
||||
func networkErrorAlert(_ r: ChatResponse) -> Bool {
|
||||
let am = AlertManager.shared
|
||||
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||
switch r {
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||
am.showAlertMsg(
|
||||
return mkAlert(
|
||||
title: "Connection timeout",
|
||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||
)
|
||||
return true
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
|
||||
am.showAlertMsg(
|
||||
return mkAlert(
|
||||
title: "Connection error",
|
||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||
)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,7 +900,9 @@ func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
|
||||
let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId)
|
||||
let cInfo = chat.chatInfo
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
|
||||
await MainActor.run { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
|
||||
await MainActor.run {
|
||||
withAnimation { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
|
||||
}
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
await markChatUnread(chat, unreadChat: false)
|
||||
@@ -866,7 +916,9 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
try await apiChatUnread(type: cInfo.chatType, id: cInfo.apiId, unreadChat: unreadChat)
|
||||
await MainActor.run { ChatModel.shared.markChatUnread(cInfo, unreadChat: unreadChat) }
|
||||
await MainActor.run {
|
||||
withAnimation { ChatModel.shared.markChatUnread(cInfo, unreadChat: unreadChat) }
|
||||
}
|
||||
} catch {
|
||||
logger.error("markChatUnread apiChatUnread error: \(responseError(error))")
|
||||
}
|
||||
@@ -950,12 +1002,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
|
||||
@@ -1028,6 +1074,7 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
if m.currentUser == nil {
|
||||
onboardingStageDefault.set(.step1_SimpleXInfo)
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
} else if start {
|
||||
try startChat(refreshInvitations: refreshInvitations)
|
||||
@@ -1053,9 +1100,10 @@ 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
|
||||
}
|
||||
}
|
||||
ChatReceiver.shared.start()
|
||||
@@ -1098,7 +1146,6 @@ func changeActiveUserAsync_(_ userId: Int64, viewPwd: String?) async throws {
|
||||
func getUserChatData() throws {
|
||||
let m = ChatModel.shared
|
||||
m.userAddress = try apiGetUserAddress()
|
||||
(m.userSMPServers, m.presetSMPServers) = try getUserSMPServers()
|
||||
m.chatItemTTL = try getChatItemTTL()
|
||||
let chats = try apiGetChats()
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
@@ -1106,13 +1153,11 @@ func getUserChatData() throws {
|
||||
|
||||
private func getUserChatDataAsync() async throws {
|
||||
let userAddress = try await apiGetUserAddressAsync()
|
||||
let servers = try await getUserSMPServersAsync()
|
||||
let chatItemTTL = try await getChatItemTTLAsync()
|
||||
let chats = try await apiGetChatsAsync()
|
||||
await MainActor.run {
|
||||
let m = ChatModel.shared
|
||||
m.userAddress = userAddress
|
||||
(m.userSMPServers, m.presetSMPServers) = servers
|
||||
m.chatItemTTL = chatItemTTL
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
}
|
||||
@@ -1238,15 +1283,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
|
||||
m.increaseUnreadCounter(user: user)
|
||||
}
|
||||
if let file = cItem.file,
|
||||
let mc = cItem.content.msgContent,
|
||||
file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV {
|
||||
let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
||||
if (mc.isImage && acceptImages)
|
||||
|| (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) {
|
||||
Task {
|
||||
await receiveFile(user: user, fileId: file.fileId) // TODO check inlineFileMode != IFMSent
|
||||
}
|
||||
if let file = cItem.autoReceiveFile() {
|
||||
Task {
|
||||
await receiveFile(user: user, fileId: file.fileId)
|
||||
}
|
||||
}
|
||||
if cItem.showNotification {
|
||||
@@ -1268,6 +1307,10 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
case let .chatItemUpdated(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
case let .chatItemReaction(user, _, r):
|
||||
if active(user) {
|
||||
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
|
||||
}
|
||||
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
|
||||
if !active(user) {
|
||||
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
|
||||
@@ -1322,10 +1365,13 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
if active(user) {
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
case let .connectedToGroupMember(user, groupInfo, member):
|
||||
case let .connectedToGroupMember(user, groupInfo, member, memberContact):
|
||||
if active(user) {
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
if let contact = memberContact {
|
||||
m.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
case let .groupUpdated(user, toGroup):
|
||||
if active(user) {
|
||||
m.updateGroup(toGroup)
|
||||
@@ -1334,31 +1380,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)
|
||||
cleanupFile(aChatItem)
|
||||
case let .sndFileError(user, aChatItem):
|
||||
chatItemSimpleUpdate(user, aChatItem)
|
||||
cleanupFile(aChatItem)
|
||||
case let .callInvitation(invitation):
|
||||
m.callInvitations[invitation.contact.id] = invitation
|
||||
activateCall(invitation)
|
||||
|
||||
@@ -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])) {
|
||||
@@ -76,6 +75,7 @@ struct ChatInfoView: View {
|
||||
case clearChatAlert
|
||||
case networkStatusAlert
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
@@ -84,6 +84,7 @@ struct ChatInfoView: View {
|
||||
case .clearChatAlert: return "clearChatAlert"
|
||||
case .networkStatusAlert: return "networkStatusAlert"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
@@ -116,17 +117,39 @@ struct ChatInfoView: View {
|
||||
contactPreferencesButton()
|
||||
}
|
||||
|
||||
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") {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
}
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
smpServers("Receiving via", connStats.rcvServers)
|
||||
smpServers("Sending via", connStats.sndServers)
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil })
|
||||
if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch })
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +174,7 @@ struct ChatInfoView: View {
|
||||
case .clearChatAlert: return clearChatAlert()
|
||||
case .networkStatusAlert: return networkStatusAlert()
|
||||
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
@@ -344,7 +368,8 @@ struct ChatInfoView: View {
|
||||
private func switchContactAddress() {
|
||||
Task {
|
||||
do {
|
||||
try await apiSwitchContact(contactId: contact.apiId)
|
||||
let stats = try apiSwitchContact(contactId: contact.apiId)
|
||||
connectionStats = stats
|
||||
} catch let error {
|
||||
logger.error("switchContactAddress apiSwitchContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -354,13 +379,37 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func abortSwitchContactAddress() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiAbortSwitchContact(contact.apiId)
|
||||
connectionStats = stats
|
||||
} catch let error {
|
||||
logger.error("abortSwitchContactAddress apiAbortSwitchContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error aborting address change")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
||||
Alert(
|
||||
title: Text("Change receiving address?"),
|
||||
message: Text("This feature is experimental! It will only work if the other client has version 4.2 installed. You should see the message in the conversation once the address change is completed – please check that you can still receive messages from this contact (or group member)."),
|
||||
primaryButton: .destructive(Text("Change"), action: switchAddress),
|
||||
message: Text("Receiving address will be changed to a different server. Address change will complete after sender comes online."),
|
||||
primaryButton: .default(Text("Change"), action: switchAddress),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func abortSwitchAddressAlert(_ abortSwitchAddress: @escaping () -> Void) -> Alert {
|
||||
Alert(
|
||||
title: Text("Abort changing address?"),
|
||||
message: Text("Address change will be aborted. Old receiving address will be used."),
|
||||
primaryButton: .destructive(Text("Abort"), action: abortSwitchAddress),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,11 +55,13 @@ 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
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -130,6 +132,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 +148,7 @@ 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)
|
||||
}
|
||||
} else {
|
||||
fileIcon("doc.fill")
|
||||
|
||||
@@ -24,7 +24,7 @@ struct CIImageView: View {
|
||||
if let uiImage = getLoadedImage(file) {
|
||||
imageView(uiImage)
|
||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||
FullScreenImageView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
||||
}
|
||||
.onTapGesture { showFullScreenImage = true }
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
@@ -90,29 +90,29 @@ struct CIImageView: View {
|
||||
case .xftp: progressView()
|
||||
case .smp: EmptyView()
|
||||
}
|
||||
case .sndTransfer:
|
||||
progressView()
|
||||
case .sndComplete:
|
||||
Image(systemName: "checkmark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 10, height: 10)
|
||||
.foregroundColor(.white)
|
||||
.padding(13)
|
||||
case .rcvAccepted:
|
||||
Image(systemName: "ellipsis")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.white)
|
||||
.padding(11)
|
||||
case .rcvTransfer:
|
||||
progressView()
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -32,7 +32,7 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparen
|
||||
r = r + statusIconText("timer", color).font(.caption2)
|
||||
let ttl = meta.itemTimed?.ttl
|
||||
if ttl != chatTTL {
|
||||
r = r + Text(TimedMessagesPreference.shortTtlText(ttl)).foregroundColor(color)
|
||||
r = r + Text(shortTimeText(ttl)).foregroundColor(color)
|
||||
}
|
||||
r = r + Text(" ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// 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 {
|
||||
var msgDecryptError: MsgDecryptError
|
||||
var msgCount: UInt32
|
||||
var chatItem: ChatItem
|
||||
var showMember = false
|
||||
|
||||
var body: some View {
|
||||
CIMsgError(chatItem: chatItem, showMember: showMember) {
|
||||
var message: Text
|
||||
let why = Text(decryptErrorReason)
|
||||
let permanent = Text("This error is permanent for this connection, please re-connect.")
|
||||
switch msgDecryptError {
|
||||
case .ratchetHeader:
|
||||
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why + Text("\n") + permanent
|
||||
case .tooManySkipped:
|
||||
message = Text("\(msgCount) messages skipped.") + Text("\n") + why + Text("\n") + permanent
|
||||
}
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Decryption error"), message: message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//struct CIRcvDecryptionError_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// CIRcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 1, chatItem: ChatItem.getIntegrityErrorSample())
|
||||
// }
|
||||
//}
|
||||
328
apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
Normal file
328
apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift
Normal file
@@ -0,0 +1,328 @@
|
||||
//
|
||||
// CIVideoView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Avently on 30/03/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import SimpleXChat
|
||||
|
||||
struct CIVideoView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private let chatItem: ChatItem
|
||||
private let image: String
|
||||
@State private var duration: Int
|
||||
@State private var progress: Int = 0
|
||||
@State private var videoPlaying: Bool = false
|
||||
private let maxWidth: CGFloat
|
||||
@Binding private var videoWidth: CGFloat?
|
||||
@State private var scrollProxy: ScrollViewProxy?
|
||||
@State private var preview: UIImage? = nil
|
||||
@State private var player: AVPlayer?
|
||||
@State private var url: URL?
|
||||
@State private var showFullScreenPlayer = false
|
||||
@State private var timeObserver: Any? = nil
|
||||
@State private var fullScreenTimeObserver: Any? = nil
|
||||
|
||||
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
|
||||
self.chatItem = chatItem
|
||||
self.image = image
|
||||
self._duration = State(initialValue: duration)
|
||||
self.maxWidth = maxWidth
|
||||
self._videoWidth = videoWidth
|
||||
self.scrollProxy = scrollProxy
|
||||
if let url = getLoadedVideo(chatItem.file) {
|
||||
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false))
|
||||
self._url = State(initialValue: url)
|
||||
}
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
self._preview = State(initialValue: uiImage)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let file = chatItem.file
|
||||
ZStack {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let file = file, let preview = preview, let player = player, let url = url {
|
||||
videoView(player, url, file, preview, duration)
|
||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
imageView(uiImage)
|
||||
.onTapGesture {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation:
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
case .rcvAccepted:
|
||||
switch file.fileProtocol {
|
||||
case .xftp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for video",
|
||||
message: "Video will be received when your contact completes uploading it."
|
||||
)
|
||||
case .smp:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Waiting for video",
|
||||
message: "Video will be received when your contact is online, please wait or check later!"
|
||||
)
|
||||
}
|
||||
case .rcvTransfer: () // ?
|
||||
case .rcvComplete: () // ?
|
||||
case .rcvCancelled: () // TODO
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
durationProgress()
|
||||
}
|
||||
if let file = file, case .rcvInvitation = file.fileStatus {
|
||||
Button {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
||||
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
||||
DispatchQueue.main.async { videoWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
ZStack(alignment: .center) {
|
||||
VideoPlayerView(player: player, url: url, showControls: false)
|
||||
.frame(width: w, height: w * preview.size.height / preview.size.width)
|
||||
.onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in
|
||||
if playingUrl != url {
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||
fullScreenPlayer(url)
|
||||
}
|
||||
.onTapGesture {
|
||||
switch player.timeControlStatus {
|
||||
case .playing:
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
case .paused:
|
||||
showFullScreenPlayer = true
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
if !videoPlaying {
|
||||
Button {
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
loadingIndicator()
|
||||
}
|
||||
.onAppear {
|
||||
addObserver(player, url)
|
||||
}
|
||||
.onDisappear {
|
||||
removeObserver()
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
}
|
||||
}
|
||||
|
||||
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 12, height: 12)
|
||||
.foregroundColor(color)
|
||||
.padding(.leading, 4)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func durationProgress() -> some View {
|
||||
HStack {
|
||||
Text("\(durationText(videoPlaying ? progress : duration))")
|
||||
.foregroundColor(.white)
|
||||
.font(.caption)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.cornerRadius(10)
|
||||
.padding([.top, .leading], 6)
|
||||
|
||||
if let file = chatItem.file, !videoPlaying {
|
||||
Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
|
||||
.foregroundColor(.white)
|
||||
.font(.caption)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.cornerRadius(10)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : .infinity
|
||||
DispatchQueue.main.async { videoWidth = w }
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: w)
|
||||
loadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func loadingIndicator() -> some View {
|
||||
if let file = chatItem.file {
|
||||
switch file.fileStatus {
|
||||
case .sndStored:
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressView()
|
||||
case .smp: EmptyView()
|
||||
}
|
||||
case let .sndTransfer(sndProgress, sndTotal):
|
||||
switch file.fileProtocol {
|
||||
case .xftp: progressCircle(sndProgress, sndTotal)
|
||||
case .smp: 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 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)
|
||||
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)
|
||||
.frame(width: 16, height: 16)
|
||||
.tint(.white)
|
||||
.padding(11)
|
||||
}
|
||||
|
||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||
Circle()
|
||||
.trim(from: 0, to: Double(progress) / Double(total))
|
||||
.stroke(
|
||||
Color(uiColor: .white),
|
||||
style: StrokeStyle(lineWidth: 2)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 16, height: 16)
|
||||
.padding([.trailing, .top], 11)
|
||||
}
|
||||
|
||||
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64) async -> Void) {
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user, file.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fullScreenPlayer(_ url: URL) -> some View {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
VideoPlayer(player: createFullScreenPlayerAndPlay(url)) {
|
||||
}
|
||||
.overlay(alignment: .topLeading, content: {
|
||||
Button(action: { showFullScreenPlayer = false },
|
||||
label: {
|
||||
Image(systemName: "multiply")
|
||||
.resizable()
|
||||
.tint(.white)
|
||||
.frame(width: 15, height: 15)
|
||||
.padding(.leading, 15)
|
||||
.padding(.top, 13)
|
||||
}
|
||||
)
|
||||
})
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 80)
|
||||
.onChanged { gesture in
|
||||
let t = gesture.translation
|
||||
let w = abs(t.width)
|
||||
if t.height > 60 && t.height > w * 2 {
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
}
|
||||
)
|
||||
.onDisappear {
|
||||
if let fullScreenTimeObserver = fullScreenTimeObserver {
|
||||
NotificationCenter.default.removeObserver(fullScreenTimeObserver)
|
||||
}
|
||||
fullScreenTimeObserver = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createFullScreenPlayerAndPlay(_ url: URL) -> AVPlayer {
|
||||
let player = AVPlayer(url: url)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
return player
|
||||
}
|
||||
|
||||
private func addObserver(_ player: AVPlayer, _ url: URL) {
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
|
||||
if let item = player.currentItem {
|
||||
let dur = CMTimeGetSeconds(item.duration)
|
||||
if !dur.isInfinite && !dur.isNaN {
|
||||
duration = Int(dur)
|
||||
}
|
||||
progress = Int(CMTimeGetSeconds(player.currentTime()))
|
||||
// `if` prevents showing Play button while the playback seeks to start and then plays
|
||||
if player.currentTime() != player.currentItem?.duration && player.currentTime() != .zero {
|
||||
videoPlaying = player.timeControlStatus == .playing || player.timeControlStatus == .waitingToPlayAtSpecifiedRate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeObserver() {
|
||||
if let timeObserver = timeObserver {
|
||||
player?.removeTimeObserver(timeObserver)
|
||||
}
|
||||
timeObserver = nil
|
||||
}
|
||||
}
|
||||
@@ -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,36 @@ 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))
|
||||
}
|
||||
} 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 +214,18 @@ struct VoiceMessagePlayer: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadButton(_ recordingFile: CIFile) -> some View {
|
||||
Button {
|
||||
Task {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProgressCircle: View {
|
||||
var length: TimeInterval
|
||||
@Binding var progress: TimeInterval?
|
||||
@@ -202,8 +251,7 @@ struct VoiceMessagePlayer: View {
|
||||
}
|
||||
|
||||
private func startPlayback(_ recordingFileName: String) {
|
||||
startingPlayback = true
|
||||
chatModel.stopPreviousRecPlay.toggle()
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
onFinishPlayback: {
|
||||
@@ -211,8 +259,7 @@ struct VoiceMessagePlayer: View {
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileName: recordingFileName)
|
||||
playbackTime = TimeInterval(0)
|
||||
audioPlayer?.start(fileName: recordingFileName, at: playbackTime)
|
||||
playbackState = .playing
|
||||
}
|
||||
}
|
||||
@@ -238,13 +285,15 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||
duration: 30,
|
||||
playbackState: .playing,
|
||||
playbackTime: TimeInterval(20)
|
||||
audioPlayer: .constant(nil),
|
||||
playbackState: .constant(.playing),
|
||||
playbackTime: .constant(TimeInterval(20)),
|
||||
allowMenu: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
|
||||
@@ -15,9 +15,15 @@ struct FramedCIVoiceView: View {
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State var playbackTime: TimeInterval?
|
||||
|
||||
|
||||
@Binding var allowMenu: Bool
|
||||
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
|
||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VoiceMessagePlayer(
|
||||
@@ -25,8 +31,11 @@ struct FramedCIVoiceView: View {
|
||||
recordingFile: recordingFile,
|
||||
recordingTime: TimeInterval(duration),
|
||||
showBackground: false,
|
||||
seek: $seek,
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
playbackTime: $playbackTime,
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
VoiceMessagePlayerTime(
|
||||
recordingTime: TimeInterval(duration),
|
||||
@@ -35,12 +44,31 @@ struct FramedCIVoiceView: View {
|
||||
)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 50, alignment: .leading)
|
||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||
playbackSlider()
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 6)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.bottom, chatItem.content.text.isEmpty ? 10 : 0)
|
||||
}
|
||||
|
||||
private func playbackSlider() -> some View {
|
||||
ComposeVoiceView.SliderBar(
|
||||
length: TimeInterval(duration),
|
||||
progress: $playbackTime,
|
||||
seek: {
|
||||
let time = max(0.0001, $0)
|
||||
seek(time)
|
||||
playbackTime = time
|
||||
})
|
||||
.onChange(of: .playing == playbackState || (playbackTime ?? 0) > 0) { show in
|
||||
if !show {
|
||||
allowMenu = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,36 +9,61 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import SwiftyGif
|
||||
import AVKit
|
||||
|
||||
struct FullScreenImageView: View {
|
||||
struct FullScreenMediaView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State var chatItem: ChatItem
|
||||
@State var image: UIImage
|
||||
@State var image: UIImage?
|
||||
@State var player: AVPlayer? = nil
|
||||
@State var url: URL? = nil
|
||||
@Binding var showView: Bool
|
||||
@State var scrollProxy: ScrollViewProxy?
|
||||
@State private var showNext = false
|
||||
@State private var nextImage: UIImage?
|
||||
@State private var nextPlayer: AVPlayer?
|
||||
@State private var nextURL: URL?
|
||||
@State private var scrolling = false
|
||||
@State private var offset: CGFloat = 0
|
||||
@State private var nextOffset: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader(content: imageScrollView)
|
||||
GeometryReader(content: mediaScrollView)
|
||||
}
|
||||
|
||||
func imageScrollView(_ g: GeometryProxy) -> some View {
|
||||
func mediaScrollView(_ g: GeometryProxy) -> some View {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
if showNext, let nextImage = nextImage {
|
||||
imageView(image).offset(x: offset)
|
||||
if let image = image {
|
||||
imageView(image).offset(x: offset)
|
||||
} else if let player = player, let url = url {
|
||||
videoView(player, url).offset(x: offset)
|
||||
}
|
||||
imageView(nextImage).offset(x: offset + nextOffset)
|
||||
} else if showNext, let nextPlayer = nextPlayer, let nextURL = nextURL {
|
||||
if let image = image {
|
||||
imageView(image).offset(x: offset)
|
||||
} else if let player = player, let url = url {
|
||||
videoView(player, url).offset(x: offset)
|
||||
}
|
||||
videoView(nextPlayer, nextURL).offset(x: offset + nextOffset)
|
||||
} else {
|
||||
ZoomableScrollView {
|
||||
imageView(image)
|
||||
if let image = image {
|
||||
imageView(image)
|
||||
} else if let player = player, let url = url {
|
||||
videoView(player, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onTapGesture { showView = false }
|
||||
.onAppear {
|
||||
startPlayerAndNotify()
|
||||
}
|
||||
.onDisappear {
|
||||
player?.pause()
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 80)
|
||||
.onChanged { gesture in
|
||||
@@ -53,9 +78,17 @@ struct FullScreenImageView: View {
|
||||
let previous = t.width > 0
|
||||
scrolling = true
|
||||
if let item = m.nextChatItemData(chatItem.id, previous: previous, map: chatItemImage) {
|
||||
var img: UIImage
|
||||
(chatItem, img) = item
|
||||
var img: UIImage?
|
||||
var url: URL?
|
||||
(chatItem, img, url) = item
|
||||
nextImage = img
|
||||
nextPlayer?.pause()
|
||||
if let url = url {
|
||||
nextPlayer = VideoPlayerView.getOrCreatePlayer(url, true)
|
||||
} else {
|
||||
nextPlayer = nil
|
||||
}
|
||||
nextURL = url
|
||||
let s = g.size.width
|
||||
var toOffset: CGFloat
|
||||
(toOffset, nextOffset) = previous ? (s, -s) : (-s, s)
|
||||
@@ -65,6 +98,14 @@ struct FullScreenImageView: View {
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
image = img
|
||||
player?.pause()
|
||||
self.url = url
|
||||
if let url = url {
|
||||
player = VideoPlayerView.getOrCreatePlayer(url, true)
|
||||
startPlayerAndNotify()
|
||||
} else {
|
||||
player = nil
|
||||
}
|
||||
showNext = false
|
||||
offset = 0
|
||||
}
|
||||
@@ -87,13 +128,30 @@ struct FullScreenImageView: View {
|
||||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
.onTapGesture { showView = false }
|
||||
}
|
||||
|
||||
private func chatItemImage(_ ci: ChatItem) -> (ChatItem, UIImage)? {
|
||||
private func videoView( _ player: AVPlayer, _ url: URL) -> some View {
|
||||
VideoPlayerView(player: player, url: url, showControls: true)
|
||||
}
|
||||
|
||||
private func chatItemImage(_ ci: ChatItem) -> (ChatItem, UIImage?, URL?)? {
|
||||
if case .image = ci.content.msgContent,
|
||||
let img = getLoadedImage(ci.file) {
|
||||
return (ci, img)
|
||||
return (ci, img, nil)
|
||||
}
|
||||
// Currently, video support in gallery is not enabled
|
||||
/*else if case .video = ci.content.msgContent,
|
||||
let url = getLoadedVideo(ci.file) {
|
||||
return (ci, nil, url)
|
||||
}*/
|
||||
return nil
|
||||
}
|
||||
|
||||
private func startPlayerAndNotify() {
|
||||
if let player = player {
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
191
apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
Normal file
191
apps/ios/Shared/Views/Chat/ChatItemInfoView.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// ChatItemInfoView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 09.05.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChatItemInfoView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var ci: ChatItem
|
||||
@Binding var chatItemInfo: ChatItemInfo?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
itemInfoView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button { showShareSheet(items: [itemInfoShareText()]) } label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
ci.chatDir.sent
|
||||
? NSLocalizedString("Sent message", comment: "message info title")
|
||||
: NSLocalizedString("Received message", comment: "message info title")
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemInfoView() -> some View {
|
||||
let meta = ci.meta
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(title)
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom)
|
||||
|
||||
let maxWidth = (g.size.width - 32) * 0.84
|
||||
infoRow("Sent at", localTimestamp(meta.itemTs))
|
||||
if !ci.chatDir.sent {
|
||||
infoRow("Received at", localTimestamp(meta.createdAt))
|
||||
}
|
||||
switch (meta.itemDeleted) {
|
||||
case let .deleted(deletedTs):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Deleted at", localTimestamp(deletedTs))
|
||||
}
|
||||
case let .moderated(deletedTs, _):
|
||||
if let deletedTs = deletedTs {
|
||||
infoRow("Moderated at", localTimestamp(deletedTs))
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
if let deleteAt = meta.itemTimed?.deleteAt {
|
||||
infoRow("Disappears at", localTimestamp(deleteAt))
|
||||
}
|
||||
if developerTools {
|
||||
infoRow("Database ID", "\(meta.itemId)")
|
||||
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
||||
}
|
||||
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
Divider().padding(.vertical)
|
||||
|
||||
Text("History")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 4)
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(Array(chatItemInfo.itemVersions.enumerated()), id: \.element.chatItemVersionId) { index, itemVersion in
|
||||
itemVersionView(itemVersion, maxWidth, current: index == 0 && ci.meta.itemDeleted == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
versionText(itemVersion)
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(ci, colorScheme))
|
||||
.cornerRadius(18)
|
||||
.contextMenu {
|
||||
if itemVersion.msgContent.text != "" {
|
||||
Button {
|
||||
showShareSheet(items: [itemVersion.msgContent.text])
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Button {
|
||||
UIPasteboard.general.string = itemVersion.msgContent.text
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
let ts = localTimestamp(itemVersion.itemVersionTs)
|
||||
(current ? Text("\(ts) (current)") : Text(ts))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder private func versionText(_ itemVersion: ChatItemVersion) -> some View {
|
||||
if itemVersion.msgContent.text != "" {
|
||||
messageText(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||
} else {
|
||||
Text("no text")
|
||||
.italic()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func itemInfoShareText() -> String {
|
||||
let meta = ci.meta
|
||||
var shareText: [String] = [title, ""]
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Sent at: %@", comment: "copied message info"), localTimestamp(meta.itemTs))]
|
||||
if !ci.chatDir.sent {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
|
||||
}
|
||||
switch (ci.meta.itemDeleted) {
|
||||
case let .deleted(deletedTs):
|
||||
if let deletedTs = deletedTs {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Deleted at: %@", comment: "copied message info"), localTimestamp(deletedTs))]
|
||||
}
|
||||
case let .moderated(deletedTs, _):
|
||||
if let deletedTs = deletedTs {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Moderated at: %@", comment: "copied message info"), localTimestamp(deletedTs))]
|
||||
}
|
||||
default: ()
|
||||
}
|
||||
if let deleteAt = meta.itemTimed?.deleteAt {
|
||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Disappears at: %@", comment: "copied message info"), localTimestamp(deleteAt))]
|
||||
}
|
||||
if developerTools {
|
||||
shareText += [
|
||||
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
|
||||
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt))
|
||||
]
|
||||
}
|
||||
if let chatItemInfo = chatItemInfo,
|
||||
!chatItemInfo.itemVersions.isEmpty {
|
||||
shareText += ["", NSLocalizedString("History", comment: "copied message info")]
|
||||
for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() {
|
||||
let t = itemVersion.msgContent.text
|
||||
shareText += [
|
||||
"",
|
||||
String.localizedStringWithFormat(
|
||||
index == 0 && ci.meta.itemDeleted == nil
|
||||
? NSLocalizedString("%@ (current):", comment: "copied message info")
|
||||
: NSLocalizedString("%@:", comment: "copied message info"),
|
||||
localTimestamp(itemVersion.itemVersionTs)
|
||||
),
|
||||
t != "" ? t : NSLocalizedString("no text", comment: "copied message info in history")
|
||||
]
|
||||
}
|
||||
}
|
||||
return shareText.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func localTimestamp(_ date: Date) -> String {
|
||||
let localDateFormatter = DateFormatter()
|
||||
localDateFormatter.dateStyle = .medium
|
||||
localDateFormatter.timeStyle = .medium
|
||||
return localDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
struct ChatItemInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), chatItemInfo: Binding.constant(nil))
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,22 @@ struct ChatItemView: View {
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@State var scrollProxy: ScrollViewProxy? = nil
|
||||
@Binding var revealed: Bool
|
||||
@Binding var allowMenu: Bool
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding<Bool>, allowMenu: Binding<Bool> = .constant(false), audioPlayer: Binding<AudioPlayer?> = .constant(nil), playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback), playbackTime: Binding<TimeInterval?> = .constant(nil)) {
|
||||
self.chatInfo = chatInfo
|
||||
self.chatItem = chatItem
|
||||
self.showMember = showMember
|
||||
self.maxWidth = maxWidth
|
||||
_scrollProxy = .init(initialValue: scrollProxy)
|
||||
_revealed = revealed
|
||||
_allowMenu = allowMenu
|
||||
_audioPlayer = audioPlayer
|
||||
_playbackState = playbackState
|
||||
_playbackTime = playbackTime
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let ci = chatItem
|
||||
@@ -25,7 +41,7 @@ struct ChatItemView: View {
|
||||
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||
EmojiItemView(chatItem: ci)
|
||||
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
||||
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration)
|
||||
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
|
||||
} else if ci.content.msgContent == nil {
|
||||
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||
} else {
|
||||
@@ -37,7 +53,7 @@ struct ChatItemView: View {
|
||||
}
|
||||
|
||||
private func framedItemView() -> some View {
|
||||
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy)
|
||||
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,6 +78,7 @@ struct ChatView: View {
|
||||
if chatModel.chatId == nil { dismiss() }
|
||||
}
|
||||
.onDisappear {
|
||||
VideoPlayerView.players.removeAll()
|
||||
if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented {
|
||||
chatModel.chatId = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
@@ -139,12 +140,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)
|
||||
@@ -188,7 +193,7 @@ struct ChatView: View {
|
||||
.focused($searchFocussed)
|
||||
.foregroundColor(.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
@@ -199,7 +204,7 @@ struct ChatView: View {
|
||||
.foregroundColor(.secondary)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(10.0)
|
||||
|
||||
|
||||
Button ("Cancel") {
|
||||
searchText = ""
|
||||
searchMode = false
|
||||
@@ -214,17 +219,25 @@ struct ChatView: View {
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func voiceWithoutFrame(_ ci: ChatItem) -> Bool {
|
||||
ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil
|
||||
}
|
||||
|
||||
private func chatItemsList() -> some View {
|
||||
let cInfo = chat.chatInfo
|
||||
return GeometryReader { g in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
let maxWidth =
|
||||
cInfo.chatType == .group
|
||||
? (g.size.width - 28) * 0.84 - 42
|
||||
: (g.size.width - 32) * 0.84
|
||||
LazyVStack(spacing: 5) {
|
||||
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
|
||||
let voiceNoFrame = voiceWithoutFrame(ci)
|
||||
let maxWidth = cInfo.chatType == .group
|
||||
? voiceNoFrame
|
||||
? (g.size.width - 28) - 42
|
||||
: (g.size.width - 28) * 0.84 - 42
|
||||
: voiceNoFrame
|
||||
? (g.size.width - 32)
|
||||
: (g.size.width - 32) * 0.84
|
||||
chatItemView(ci, maxWidth)
|
||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||
.onAppear {
|
||||
@@ -430,6 +443,7 @@ struct ChatView: View {
|
||||
|
||||
private struct ChatItemWithMenu: View {
|
||||
@EnvironmentObject var chat: Chat
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var ci: ChatItem
|
||||
var showMember: Bool = false
|
||||
var maxWidth: CGFloat
|
||||
@@ -440,16 +454,30 @@ struct ChatView: View {
|
||||
@Binding var showDeleteMessage: Bool
|
||||
|
||||
@State private var revealed = false
|
||||
@State private var showChatItemInfoSheet: Bool = false
|
||||
@State private var chatItemInfo: ChatItemInfo?
|
||||
|
||||
@State private var allowMenu: Bool = true
|
||||
|
||||
@State private var audioPlayer: AudioPlayer?
|
||||
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State private var playbackTime: TimeInterval?
|
||||
|
||||
var body: some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
let uiMenu: Binding<UIMenu> = Binding(
|
||||
get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) },
|
||||
set: { _ in }
|
||||
)
|
||||
|
||||
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed)
|
||||
.uiKitContextMenu(menu: uiMenu)
|
||||
|
||||
VStack(alignment: alignment.horizontal, spacing: 3) {
|
||||
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
|
||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||
chatItemReactions()
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessage(.cidmInternal)
|
||||
@@ -462,11 +490,69 @@ struct ChatView: View {
|
||||
}
|
||||
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||
.onDisappear {
|
||||
if ci.content.msgContent?.isVoice == true {
|
||||
allowMenu = true
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
|
||||
chatItemInfo = nil
|
||||
}) {
|
||||
ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private func menu(live: Bool) -> [UIAction] {
|
||||
var menu: [UIAction] = []
|
||||
|
||||
private func chatItemReactions() -> some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(ci.reactions, id: \.reaction) { r in
|
||||
let v = HStack(spacing: 4) {
|
||||
switch r.reaction {
|
||||
case let .emoji(emoji): Text(emoji.rawValue).font(.caption)
|
||||
case .unknown: EmptyView()
|
||||
}
|
||||
if r.totalReacted > 1 {
|
||||
Text("\(r.totalReacted)")
|
||||
.font(.caption)
|
||||
.fontWeight(r.userReacted ? .bold : .light)
|
||||
.foregroundColor(r.userReacted ? .accentColor : .secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
|
||||
v.onTapGesture {
|
||||
setReaction(add: !r.userReacted, reaction: r.reaction)
|
||||
}
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func menu(live: Bool) -> [UIMenuElement] {
|
||||
var menu: [UIMenuElement] = []
|
||||
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
|
||||
let rs = allReactions()
|
||||
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
|
||||
rs.count > 0 {
|
||||
var rm: UIMenu
|
||||
if #available(iOS 16, *) {
|
||||
var children: [UIMenuElement] = Array(rs.prefix(topReactionsCount(rs)))
|
||||
if let sm = reactionUIMenu(rs) {
|
||||
children.append(sm)
|
||||
}
|
||||
rm = UIMenu(title: "", options: .displayInline, children: children)
|
||||
rm.preferredElementSize = .small
|
||||
} else {
|
||||
rm = reactionUIMenuPreiOS16(rs)
|
||||
}
|
||||
menu.append(rm)
|
||||
}
|
||||
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
|
||||
menu.append(replyUIAction())
|
||||
}
|
||||
@@ -486,13 +572,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())
|
||||
@@ -504,6 +591,7 @@ struct ChatView: View {
|
||||
if !ci.isDeletedContent {
|
||||
menu.append(revealUIAction())
|
||||
}
|
||||
menu.append(viewInfoUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
} else if ci.isDeletedContent {
|
||||
menu.append(deleteUIAction())
|
||||
@@ -525,7 +613,59 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func reactionUIMenuPreiOS16(_ rs: [UIAction]) -> UIMenu {
|
||||
UIMenu(
|
||||
title: NSLocalizedString("React...", comment: "chat item menu"),
|
||||
image: UIImage(systemName: "face.smiling"),
|
||||
children: rs
|
||||
)
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
private func reactionUIMenu(_ rs: [UIAction]) -> UIMenu? {
|
||||
var children = rs
|
||||
children.removeFirst(min(rs.count, topReactionsCount(rs)))
|
||||
if children.count == 0 { return nil }
|
||||
return UIMenu(
|
||||
title: "",
|
||||
image: UIImage(systemName: "ellipsis"),
|
||||
children: children
|
||||
)
|
||||
}
|
||||
|
||||
private func allReactions() -> [UIAction] {
|
||||
MsgReaction.values.compactMap { r in
|
||||
ci.reactions.contains(where: { $0.userReacted && $0.reaction == r })
|
||||
? nil
|
||||
: UIAction(title: r.text) { _ in setReaction(add: true, reaction: r) }
|
||||
}
|
||||
}
|
||||
|
||||
private func topReactionsCount(_ rs: [UIAction]) -> Int {
|
||||
rs.count > 4 ? 3 : 4
|
||||
}
|
||||
|
||||
private func setReaction(add: Bool, reaction: MsgReaction) {
|
||||
Task {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let chatItem = try await apiChatItemReaction(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
itemId: ci.id,
|
||||
add: add,
|
||||
reaction: reaction
|
||||
)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiChatItemReaction error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shareUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Share", comment: "chat item action"),
|
||||
@@ -584,16 +724,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)
|
||||
@@ -664,7 +824,7 @@ struct ChatView: View {
|
||||
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
||||
switch (prevItem?.chatDir) {
|
||||
case .groupSnd: return true
|
||||
@@ -737,9 +897,20 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.enableNtfs = enableNtfs
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
func toggleChatFavorite(_ chat: Chat, favorite: Bool) {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.favorite = favorite
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
|
||||
Task {
|
||||
do {
|
||||
let chatSettings = ChatSettings(enableNtfs: enableNtfs)
|
||||
try await apiSetChatSettings(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, chatSettings: chatSettings)
|
||||
await MainActor.run {
|
||||
switch chat.chatInfo {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@ import PhotosUI
|
||||
enum ComposePreview {
|
||||
case noPreview
|
||||
case linkPreview(linkPreview: LinkPreview?)
|
||||
case imagePreviews(imagePreviews: [(String, UploadContent?)])
|
||||
case mediaPreviews(mediaPreviews: [(String, UploadContent?)])
|
||||
case voicePreview(recordingFileName: String, duration: Int)
|
||||
case filePreview(fileName: String, file: URL)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ struct ComposeState {
|
||||
|
||||
var sendEnabled: Bool {
|
||||
switch preview {
|
||||
case .imagePreviews: return true
|
||||
case .mediaPreviews: return true
|
||||
case .voicePreview: return voiceMessageRecordingState == .finished
|
||||
case .filePreview: return true
|
||||
default: return !message.isEmpty || liveMessage != nil
|
||||
@@ -118,7 +118,7 @@ struct ComposeState {
|
||||
|
||||
var linkPreviewAllowed: Bool {
|
||||
switch preview {
|
||||
case .imagePreviews: return false
|
||||
case .mediaPreviews: return false
|
||||
case .voicePreview: return false
|
||||
case .filePreview: return false
|
||||
default: return useLinkPreviews
|
||||
@@ -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
|
||||
@@ -175,7 +175,9 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
case let .link(_, preview: preview):
|
||||
chatItemPreview = .linkPreview(linkPreview: preview)
|
||||
case let .image(_, image):
|
||||
chatItemPreview = .imagePreviews(imagePreviews: [(image, nil)])
|
||||
chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
case let .video(_, image, _):
|
||||
chatItemPreview = .mediaPreviews(mediaPreviews: [(image, nil)])
|
||||
case let .voice(_, duration):
|
||||
chatItemPreview = .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
|
||||
case .file:
|
||||
@@ -190,11 +192,13 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
enum UploadContent: Equatable {
|
||||
case simpleImage(image: UIImage)
|
||||
case animatedImage(image: UIImage)
|
||||
case video(image: UIImage, url: URL, duration: Int)
|
||||
|
||||
var uiImage: UIImage {
|
||||
switch self {
|
||||
case let .simpleImage(image): return image
|
||||
case let .animatedImage(image): return image
|
||||
case let .video(image, _, _): return image
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +220,14 @@ enum UploadContent: Equatable {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func loadVideoFromURL(url: URL) -> UploadContent? {
|
||||
let asset = AVAsset(url: url)
|
||||
if let (image, duration) = asset.generatePreview() {
|
||||
return .video(image: image, url: url, duration: duration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
@@ -229,12 +241,10 @@ struct ComposeView: View {
|
||||
@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 showImagePicker = false
|
||||
@State private var showMediaPicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@State var chosenImages: [UploadContent] = []
|
||||
@State var chosenMedia: [UploadContent] = []
|
||||
@State private var showFileImporter = false
|
||||
|
||||
@State private var audioRecorder: AudioRecorder?
|
||||
@@ -254,7 +264,7 @@ struct ComposeView: View {
|
||||
default: previewView()
|
||||
}
|
||||
HStack (alignment: .bottom) {
|
||||
Button {
|
||||
let b = Button {
|
||||
showChooseSource = true
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
@@ -264,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,
|
||||
@@ -286,7 +307,8 @@ struct ComposeView: View {
|
||||
},
|
||||
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
||||
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
|
||||
onImagesAdded: { images in if !images.isEmpty { chosenImages = images }},
|
||||
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
|
||||
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
@@ -329,7 +351,7 @@ struct ComposeView: View {
|
||||
showTakePhoto = true
|
||||
}
|
||||
Button("Choose from library") {
|
||||
showImagePicker = true
|
||||
showMediaPicker = true
|
||||
}
|
||||
if UIPasteboard.general.hasImages {
|
||||
Button("Paste image") {
|
||||
@@ -337,7 +359,7 @@ struct ComposeView: View {
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
||||
if let url = url, let image = UploadContent.loadFromURL(url: url) {
|
||||
chosenImages.append(image)
|
||||
chosenMedia.append(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,31 +373,31 @@ struct ComposeView: View {
|
||||
.fullScreenCover(isPresented: $showTakePhoto) {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
CameraImageListPicker(images: $chosenImages)
|
||||
CameraImageListPicker(images: $chosenMedia)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
LibraryImageListPicker(images: $chosenImages, selectionLimit: 10) { itemsSelected in
|
||||
showImagePicker = false
|
||||
.sheet(isPresented: $showMediaPicker) {
|
||||
LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in
|
||||
showMediaPicker = false
|
||||
if itemsSelected {
|
||||
DispatchQueue.main.async {
|
||||
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: []))
|
||||
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: chosenImages) { images in
|
||||
.onChange(of: chosenMedia) { selected in
|
||||
Task {
|
||||
var imgs: [(String, UploadContent)] = []
|
||||
for image in images {
|
||||
if let img = resizeImageToStrSize(image.uiImage, maxDataSize: 14000) {
|
||||
imgs.append((img, image))
|
||||
var media: [(String, UploadContent)] = []
|
||||
for content in selected {
|
||||
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||
media.append((img, content))
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(preview: .imagePreviews(imagePreviews: imgs))
|
||||
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
|
||||
}
|
||||
}
|
||||
}
|
||||
if imgs.count == 0 {
|
||||
if media.count == 0 {
|
||||
await MainActor.run {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
}
|
||||
@@ -415,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 {
|
||||
@@ -450,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 {
|
||||
@@ -459,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))
|
||||
}
|
||||
@@ -475,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))
|
||||
}
|
||||
@@ -513,15 +536,19 @@ struct ComposeView: View {
|
||||
case .noPreview:
|
||||
EmptyView()
|
||||
case let .linkPreview(linkPreview: preview):
|
||||
ComposeLinkView(linkPreview: preview, cancelPreview: cancelLinkPreview)
|
||||
case let .imagePreviews(imagePreviews: images):
|
||||
ComposeLinkView(
|
||||
linkPreview: preview,
|
||||
cancelPreview: cancelLinkPreview,
|
||||
cancelEnabled: !composeState.inProgress
|
||||
)
|
||||
case let .mediaPreviews(mediaPreviews: media):
|
||||
ComposeImageView(
|
||||
images: images.map { (img, _) in img },
|
||||
images: media.map { (img, _) in img },
|
||||
cancelImage: {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
chosenImages = []
|
||||
chosenMedia = []
|
||||
},
|
||||
cancelEnabled: !composeState.editing)
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||
case let .voicePreview(recordingFileName, _):
|
||||
ComposeVoiceView(
|
||||
recordingFileName: recordingFileName,
|
||||
@@ -531,7 +558,7 @@ struct ComposeView: View {
|
||||
cancelVoiceMessageRecording($0)
|
||||
clearState()
|
||||
},
|
||||
cancelEnabled: !composeState.editing,
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress,
|
||||
stopPlayback: $stopPlayback
|
||||
)
|
||||
case let .filePreview(fileName, _):
|
||||
@@ -540,7 +567,7 @@ struct ComposeView: View {
|
||||
cancelFile: {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
},
|
||||
cancelEnabled: !composeState.editing)
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,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
|
||||
@@ -591,28 +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)
|
||||
case let .imagePreviews(imagePreviews: images):
|
||||
let last = images.count - 1
|
||||
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 {
|
||||
sent = await sendImage(images[i])
|
||||
if case (_, .video(_, _, _)) = media[i] {
|
||||
sent = await sendVideo(media[i], ttl: ttl)
|
||||
} else {
|
||||
sent = await sendImage(media[i], ttl: ttl)
|
||||
}
|
||||
_ = try? await Task.sleep(nanoseconds: 100_000000)
|
||||
}
|
||||
sent = await sendImage(images[last], text: msgText, quoted: quoted, live: live)
|
||||
if case (_, .video(_, _, _)) = media[last] {
|
||||
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, 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.removeAll { $0 == recordingFileName }
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName)
|
||||
chatModel.filesToDelete.remove(getAppFilePath(recordingFileName))
|
||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: recordingFileName, ttl: ttl)
|
||||
case let .filePreview(_, file):
|
||||
if let savedFile = saveFileFromURL(file) {
|
||||
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live)
|
||||
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -657,6 +692,8 @@ struct ComposeView: View {
|
||||
return checkLinkPreview()
|
||||
case .image(_, let image):
|
||||
return .image(text: msgText, image: image)
|
||||
case .video(_, let image, let duration):
|
||||
return .video(text: msgText, image: image, duration: duration)
|
||||
case .voice(_, let duration):
|
||||
return .voice(text: msgText, duration: duration)
|
||||
case .file:
|
||||
@@ -666,22 +703,31 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false) async -> ChatItem? {
|
||||
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
let (image, data) = imageData
|
||||
if let data = data, let savedFile = saveAnyImage(data) {
|
||||
return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live)
|
||||
return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: String? = 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, ttl: ttl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -689,6 +735,9 @@ struct ComposeView: View {
|
||||
}
|
||||
return chatItem
|
||||
}
|
||||
if let file = file {
|
||||
removeFile(file)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -711,14 +760,15 @@ struct ComposeView: View {
|
||||
switch img {
|
||||
case let .simpleImage(image): return saveImage(image)
|
||||
case let .animatedImage(image): return saveAnimImage(image)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startVoiceMessageRecording() async {
|
||||
startingRecording = true
|
||||
chatModel.stopPreviousRecPlay.toggle()
|
||||
let fileName = generateNewFileName("voice", "m4a")
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(fileName)
|
||||
audioRecorder = AudioRecorder(
|
||||
onTimer: { voiceMessageRecordingTime = $0 },
|
||||
onFinishRecording: {
|
||||
@@ -804,7 +854,7 @@ struct ComposeView: View {
|
||||
composeState = ComposeState()
|
||||
resetLinkPreview()
|
||||
}
|
||||
chosenImages = []
|
||||
chosenMedia = []
|
||||
audioRecorder = nil
|
||||
voiceMessageRecordingTime = nil
|
||||
startingRecording = false
|
||||
@@ -814,7 +864,7 @@ struct ComposeView: View {
|
||||
if case .recording = composeState.voiceMessageRecordingState {
|
||||
finishVoiceMessageRecording()
|
||||
if let fileName = composeState.voiceMessageRecordingFileName {
|
||||
chatModel.filesToDelete.append(fileName)
|
||||
chatModel.filesToDelete.insert(getAppFilePath(fileName))
|
||||
}
|
||||
}
|
||||
chatModel.draft = composeState
|
||||
|
||||
@@ -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,17 +170,17 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPlayback() {
|
||||
startingPlayback = true
|
||||
chatModel.stopPreviousRecPlay.toggle()
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName)
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
onFinishPlayback: {
|
||||
@@ -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,6 +13,7 @@ import PhotosUI
|
||||
|
||||
struct NativeTextEditor: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
@Binding var disableEditing: Bool
|
||||
let height: CGFloat
|
||||
let font: UIFont
|
||||
@FocusState.Binding var focused: Bool
|
||||
@@ -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) {
|
||||
@@ -140,6 +147,7 @@ struct NativeTextEditor_Previews: PreviewProvider{
|
||||
@FocusState var keyboardVisible: Bool
|
||||
return NativeTextEditor(
|
||||
text: Binding.constant("Hello, world!"),
|
||||
disableEditing: Binding.constant(false),
|
||||
height: 100,
|
||||
font: UIFont.preferredFont(forTextStyle: .body),
|
||||
focused: $keyboardVisible,
|
||||
|
||||
@@ -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,7 +23,8 @@ struct SendMessageView: View {
|
||||
var startVoiceMessageRecording: (() -> Void)? = nil
|
||||
var finishVoiceMessageRecording: (() -> Void)? = nil
|
||||
var allowVoiceMessagesToContact: (() -> Void)? = nil
|
||||
var onImagesAdded: ([UploadContent]) -> Void
|
||||
var timedMessageAllowed: Bool = false
|
||||
var onMediaAdded: ([UploadContent]) -> Void
|
||||
@State private var holdingVMR = false
|
||||
@Namespace var namespace
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
@@ -32,6 +33,9 @@ struct SendMessageView: View {
|
||||
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
|
||||
@State private var sendButtonSize: CGFloat = 29
|
||||
@State private var sendButtonOpacity: CGFloat = 1
|
||||
@State private var showCustomDisappearingMessageDialogue = false
|
||||
@State private var showCustomTimePicker = false
|
||||
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
|
||||
var maxHeight: CGFloat = 360
|
||||
var minHeight: CGFloat = 37
|
||||
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
@@ -65,11 +69,12 @@ struct SendMessageView: View {
|
||||
|
||||
NativeTextEditor(
|
||||
text: $composeState.message,
|
||||
disableEditing: $composeState.inProgress,
|
||||
height: teHeight,
|
||||
font: teUiFont,
|
||||
focused: $keyboardVisible,
|
||||
alignment: alignment,
|
||||
onImagesAdded: onImagesAdded
|
||||
onImagesAdded: onMediaAdded
|
||||
)
|
||||
.allowsTightening(false)
|
||||
.frame(height: teHeight)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +410,8 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
Spacer(minLength: 0)
|
||||
SendMessageView(
|
||||
composeState: $composeStateNew,
|
||||
sendMessage: {},
|
||||
onImagesAdded: { _ in },
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
@@ -374,8 +420,8 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||
Spacer(minLength: 0)
|
||||
SendMessageView(
|
||||
composeState: $composeStateEditing,
|
||||
sendMessage: {},
|
||||
onImagesAdded: { _ in },
|
||||
sendMessage: { _ in },
|
||||
onMediaAdded: { _ in },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -142,7 +152,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,26 @@ struct GroupMemberInfoView: View {
|
||||
@State private var newRole: GroupMemberRole = .member
|
||||
@State private var alert: GroupMemberInfoViewAlert?
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var justOpened = true
|
||||
|
||||
enum GroupMemberInfoViewAlert: Identifiable {
|
||||
case removeMemberAlert(mem: GroupMember)
|
||||
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case connRequestSentAlert(type: ConnReqType)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case other(alert: Alert)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .removeMemberAlert: return "removeMemberAlert"
|
||||
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case .connRequestSentAlert: return "connRequestSentAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
case let .other(alert): return "other \(alert)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +52,15 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func knownDirectChat(_ contactId: Int64) -> Chat? {
|
||||
if let chat = chatModel.getContactChat(contactId),
|
||||
chat.chatInfo.contact?.directOrUsed == true {
|
||||
return chat
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func groupMemberInfoView() -> some View {
|
||||
VStack {
|
||||
List {
|
||||
@@ -54,8 +70,7 @@ struct GroupMemberInfoView: View {
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let contactId = member.memberContactId {
|
||||
if let chat = chatModel.getContactChat(contactId),
|
||||
chat.chatInfo.contact?.directOrUsed ?? false {
|
||||
if let chat = knownDirectChat(contactId) {
|
||||
knownDirectChatButton(chat)
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on {
|
||||
newDirectChatButton(contactId)
|
||||
@@ -65,6 +80,28 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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)**.")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Member") {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
@@ -92,8 +129,15 @@ struct GroupMemberInfoView: View {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvServers)
|
||||
smpServers("Sending via", connStats.sndServers)
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil })
|
||||
if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch })
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +156,10 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let stats = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
@@ -122,6 +170,7 @@ struct GroupMemberInfoView: View {
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
justOpened = false
|
||||
}
|
||||
.onChange(of: newRole) { _ in
|
||||
if newRole != member.memberRole {
|
||||
@@ -135,7 +184,29 @@ struct GroupMemberInfoView: View {
|
||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
|
||||
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||
case let .connRequestSentAlert(type): return connReqSentAlert(type)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .other(alert): return alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaAddressButton(_ contactLink: String) -> some View {
|
||||
Button {
|
||||
connectViaAddress(contactLink)
|
||||
} label: {
|
||||
Label("Connect", systemImage: "link")
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaAddress(_ contactLink: String) {
|
||||
Task {
|
||||
let (connReqType, connectAlert) = await apiConnect_(connReq: contactLink)
|
||||
if let connReqType = connReqType {
|
||||
alert = .connRequestSentAlert(type: connReqType)
|
||||
} else if let connectAlert = connectAlert {
|
||||
alert = .other(alert: connectAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,7 +356,8 @@ struct GroupMemberInfoView: View {
|
||||
private func switchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
} catch let error {
|
||||
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error changing address")
|
||||
@@ -295,6 +367,21 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func abortSwitchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
} catch let error {
|
||||
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error aborting address change")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||
|
||||
@@ -25,7 +25,10 @@ struct GroupPreferencesView: View {
|
||||
featureSection(.timedMessages, $preferences.timedMessages.enable)
|
||||
featureSection(.fullDelete, $preferences.fullDelete.enable)
|
||||
featureSection(.directMessages, $preferences.directMessages.enable)
|
||||
featureSection(.reactions, $preferences.reactions.enable)
|
||||
featureSection(.voice, $preferences.voice.enable)
|
||||
// TODO uncomment in 5.3
|
||||
// featureSection(.files, $preferences.files.enable)
|
||||
|
||||
if groupInfo.canEdit {
|
||||
Section {
|
||||
@@ -75,14 +78,21 @@ struct GroupPreferencesView: View {
|
||||
Toggle(feature.text, isOn: enable)
|
||||
}
|
||||
if timedOn {
|
||||
timedMessagesTTLPicker($preferences.timedMessages.ttl)
|
||||
DropdownCustomTimePicker(
|
||||
selection: $preferences.timedMessages.ttl,
|
||||
label: "Delete after",
|
||||
dropdownValues: TimedMessagesPreference.ttlValues,
|
||||
customPickerConfirmButtonText: "Select",
|
||||
customPickerDescription: "Delete after"
|
||||
)
|
||||
.frame(height: 36)
|
||||
}
|
||||
} else {
|
||||
settingsRow(icon, color: color) {
|
||||
infoRow(Text(feature.text), enableFeature.wrappedValue.text)
|
||||
}
|
||||
if timedOn {
|
||||
infoRow("Delete after", TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
||||
infoRow("Delete after", timeText(preferences.timedMessages.ttl))
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -11,10 +11,11 @@ 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 {
|
||||
ZStack(alignment: .topLeading) {
|
||||
@@ -29,11 +30,7 @@ struct ChatListView: View {
|
||||
if chatModel.chats.isEmpty {
|
||||
onboardingButtons()
|
||||
}
|
||||
if chatModel.chats.count > 8 {
|
||||
chatList.searchable(text: $searchText)
|
||||
} else {
|
||||
chatList
|
||||
}
|
||||
chatListView
|
||||
}
|
||||
}
|
||||
if userPickerVisible {
|
||||
@@ -47,18 +44,12 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var chatList: some View {
|
||||
List {
|
||||
ForEach(filteredChats(), id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
|
||||
chatModel.chatToTop = nil
|
||||
chatModel.popChat(chatId)
|
||||
private var chatListView: some View {
|
||||
VStack {
|
||||
if chatModel.chats.count > 0 {
|
||||
chatList.searchable(text: $searchText)
|
||||
} else {
|
||||
chatList
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
||||
@@ -66,12 +57,11 @@ struct ChatListView: View {
|
||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
||||
.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 {
|
||||
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||
withAnimation {
|
||||
userPickerVisible.toggle()
|
||||
}
|
||||
@@ -94,17 +84,19 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
if (chatModel.incognito) {
|
||||
HStack {
|
||||
if (chatModel.chats.count > 8) {
|
||||
Text("Your chats").font(.headline)
|
||||
Spacer().frame(width: 16)
|
||||
}
|
||||
Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo)
|
||||
HStack(spacing: 4) {
|
||||
if (chatModel.incognito) {
|
||||
Image(systemName: "theatermasks")
|
||||
.foregroundColor(.indigo)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
Text("Chats")
|
||||
.font(.headline)
|
||||
if chatModel.chats.count > 0 {
|
||||
toggleFilterButton()
|
||||
}
|
||||
} else {
|
||||
Text("Your chats").font(.headline)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
switch chatModel.chatRunning {
|
||||
@@ -114,8 +106,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 +198,37 @@ struct ChatListView: View {
|
||||
|
||||
private func filteredChats() -> [Chat] {
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
return s == ""
|
||||
return s == "" && !showUnreadAndFavorites
|
||||
? chatModel.chats
|
||||
: chatModel.chats.filter { chat in
|
||||
let contains = chat.chatInfo.chatViewName.localizedLowercase.contains(s)
|
||||
switch chat.chatInfo {
|
||||
let cInfo = chat.chatInfo
|
||||
switch cInfo {
|
||||
case let .direct(contact):
|
||||
return contains
|
||||
|| contact.profile.displayName.localizedLowercase.contains(s)
|
||||
|| contact.fullName.localizedLowercase.contains(s)
|
||||
case .contactConnection: return false
|
||||
default: return contains
|
||||
return s == ""
|
||||
? filtered(chat)
|
||||
: (viewNameContains(cInfo, s) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||
contact.fullName.localizedLowercase.contains(s))
|
||||
case let .group(gInfo):
|
||||
return s == ""
|
||||
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
||||
: viewNameContains(cInfo, s)
|
||||
case .contactRequest:
|
||||
return s == "" || viewNameContains(cInfo, s)
|
||||
case let .contactConnection(conn):
|
||||
return s != "" && conn.localAlias.localizedLowercase.contains(s)
|
||||
case .invalidJSON:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func filtered(_ chat: Chat) -> Bool {
|
||||
(chat.chatInfo.chatSettings?.favorite ?? false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
||||
}
|
||||
|
||||
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
|
||||
cInfo.chatViewName.localizedLowercase.contains(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,9 +262,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +150,7 @@ struct ChatPreviewView: View {
|
||||
func attachment() -> Text {
|
||||
switch draft.preview {
|
||||
case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + Text(" ")
|
||||
case .imagePreviews: return image("photo")
|
||||
case .mediaPreviews: return image("photo")
|
||||
case let .voicePreview(_, duration): return image("play.fill") + Text(durationText(duration))
|
||||
default: return Text("")
|
||||
}
|
||||
@@ -159,6 +166,7 @@ struct ChatPreviewView: View {
|
||||
switch cItem.content.msgContent {
|
||||
case .file: return "doc.fill"
|
||||
case .image: return "photo"
|
||||
case .video: return "video"
|
||||
case .voice: return "play.fill"
|
||||
default: return nil
|
||||
}
|
||||
@@ -259,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,11 +11,12 @@ import UIKit
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func uiKitContextMenu(menu: Binding<UIMenu>) -> some View {
|
||||
self.overlay(Color(uiColor: .systemBackground))
|
||||
.overlay(
|
||||
InteractionView(content: self, menu: menu)
|
||||
)
|
||||
func uiKitContextMenu(menu: Binding<UIMenu>, allowMenu: Binding<Bool>) -> some View {
|
||||
self.overlay {
|
||||
if allowMenu.wrappedValue {
|
||||
self.overlay(Color(uiColor: .systemBackground)).overlay(InteractionView(content: self, menu: menu))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
229
apps/ios/Shared/Views/Helpers/CustomTimePicker.swift
Normal file
229
apps/ios/Shared/Views/Helpers/CustomTimePicker.swift
Normal file
@@ -0,0 +1,229 @@
|
||||
//
|
||||
// CustomTimePicker.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 11.05.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CustomTimePicker: View {
|
||||
@Binding var selection: Int?
|
||||
@State var timeUnitsLimits = TimeUnitLimits.defaultUnitsLimits
|
||||
@State private var selectedUnit: CustomTimeUnit = .second
|
||||
@State private var selectedDuration: Int = 1
|
||||
|
||||
struct TimeUnitLimits {
|
||||
var timeUnit: CustomTimeUnit
|
||||
var minValue: Int = 1
|
||||
var maxValue: Int
|
||||
|
||||
public static func defaultUnitLimits(_ unit: CustomTimeUnit) -> TimeUnitLimits {
|
||||
switch unit {
|
||||
case .second: return TimeUnitLimits.init(timeUnit: .second, maxValue: 120)
|
||||
case .minute: return TimeUnitLimits.init(timeUnit: .minute, maxValue: 120)
|
||||
case .hour: return TimeUnitLimits.init(timeUnit: .hour, maxValue: 72)
|
||||
case .day: return TimeUnitLimits.init(timeUnit: .day, maxValue: 60)
|
||||
case .week: return TimeUnitLimits.init(timeUnit: .week, maxValue: 52)
|
||||
case .month: return TimeUnitLimits.init(timeUnit: .month, maxValue: 12)
|
||||
}
|
||||
}
|
||||
|
||||
public static var defaultUnitsLimits: [TimeUnitLimits] {[
|
||||
defaultUnitLimits(.second),
|
||||
defaultUnitLimits(.minute),
|
||||
defaultUnitLimits(.hour),
|
||||
defaultUnitLimits(.day),
|
||||
defaultUnitLimits(.week),
|
||||
defaultUnitLimits(.month),
|
||||
]}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Group {
|
||||
Picker("Duration", selection: $selectedDuration) {
|
||||
let selectedUnitLimits = timeUnitsLimits.first(where: { $0.timeUnit == selectedUnit }) ?? TimeUnitLimits.defaultUnitLimits(selectedUnit)
|
||||
let selectedUnitValues = Array(selectedUnitLimits.minValue...selectedUnitLimits.maxValue)
|
||||
let values = selectedUnitValues + (selectedUnitValues.contains(selectedDuration) ? [] : [selectedDuration])
|
||||
ForEach(values, id: \.self) { value in
|
||||
Text("\(value)")
|
||||
}
|
||||
}
|
||||
Picker("Unit", selection: $selectedUnit) {
|
||||
ForEach(timeUnitsLimits.map { $0.timeUnit }, id: \.self) { timeUnit in
|
||||
Text(timeUnit.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(minWidth: 0)
|
||||
.compositingGroup()
|
||||
.clipped()
|
||||
}
|
||||
.onAppear {
|
||||
if let selection = selection,
|
||||
selection > 0 {
|
||||
(selectedUnit, selectedDuration) = CustomTimeUnit.toTimeUnit(seconds: selection)
|
||||
} else {
|
||||
selection = selectedUnit.toSeconds * selectedDuration
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedUnit) { unit in
|
||||
if let maxValue = timeUnitsLimits.first(where: { $0.timeUnit == unit })?.maxValue,
|
||||
selectedDuration > maxValue {
|
||||
selectedDuration = maxValue
|
||||
} else {
|
||||
selection = unit.toSeconds * selectedDuration
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedDuration) { duration in
|
||||
selection = selectedUnit.toSeconds * duration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIPickerView {
|
||||
open override var intrinsicContentSize: CGSize {
|
||||
return CGSize(width: UIView.noIntrinsicMetric, height: super.intrinsicContentSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomTimePickerView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Binding var selection: Int?
|
||||
var confirmButtonText: LocalizedStringKey
|
||||
var confirmButtonAction: () -> Void
|
||||
var description: LocalizedStringKey? = nil
|
||||
var timeUnitsLimits = CustomTimePicker.TimeUnitLimits.defaultUnitsLimits
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
customTimePickerView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
confirmButtonAction()
|
||||
dismiss()
|
||||
} label: {
|
||||
Text(confirmButtonText)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.disabled(selection == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func customTimePickerView() -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
List {
|
||||
Group {
|
||||
Section(description ?? "") {
|
||||
CustomTimePicker(selection: $selection)
|
||||
}
|
||||
}
|
||||
.listRowInsets(.init(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DropdownCustomTimePicker: View {
|
||||
@Binding var selection: Int?
|
||||
var label: LocalizedStringKey
|
||||
var dropdownValues: [Int?]
|
||||
var customPickerConfirmButtonText: LocalizedStringKey
|
||||
var customPickerDescription: LocalizedStringKey? = nil
|
||||
var customPickerTimeUnitsLimits = CustomTimePicker.TimeUnitLimits.defaultUnitsLimits
|
||||
@State private var dropdownSelection: DropdownSelection = .dropdownValue(value: nil)
|
||||
@State private var showCustomTimePicker = false
|
||||
@State private var selectedCustomTime: Int? = nil
|
||||
@State private var justOpened = true
|
||||
|
||||
enum DropdownSelection: Hashable {
|
||||
case dropdownValue(value: Int?)
|
||||
case custom
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Picker(label, selection: $dropdownSelection) {
|
||||
let values: [DropdownSelection] =
|
||||
dropdownValues.map { .dropdownValue(value: $0) }
|
||||
+ (dropdownValues.contains(selection) ? [] : [.dropdownValue(value: selection)])
|
||||
+ [.custom]
|
||||
ForEach(values, id: \.self) { v in
|
||||
switch v {
|
||||
case let .dropdownValue(value): Text(timeText(value))
|
||||
case .custom: Text(NSLocalizedString("custom", comment: "dropdown time picker choice"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
dropdownSelection = .dropdownValue(value: selection)
|
||||
justOpened = false
|
||||
}
|
||||
.onChange(of: selection) { v in
|
||||
logger.debug("*** .onChange(of: selection)")
|
||||
dropdownSelection = .dropdownValue(value: v)
|
||||
}
|
||||
.onChange(of: dropdownSelection) { v in
|
||||
logger.debug("*** .onChange(of: dropdownSelection)")
|
||||
switch v {
|
||||
case let .dropdownValue(value): selection = value
|
||||
case .custom: showCustomTimePicker = true
|
||||
}
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $showCustomTimePicker,
|
||||
onDismiss: {
|
||||
dropdownSelection = .dropdownValue(value: selection)
|
||||
selectedCustomTime = nil
|
||||
}
|
||||
) {
|
||||
if #available(iOS 16.0, *) {
|
||||
customTimePicker()
|
||||
.presentationDetents([.medium])
|
||||
} else {
|
||||
customTimePicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func customTimePicker() -> some View {
|
||||
CustomTimePickerView(
|
||||
selection: $selectedCustomTime,
|
||||
confirmButtonText: customPickerConfirmButtonText,
|
||||
confirmButtonAction: {
|
||||
if let time = selectedCustomTime {
|
||||
selection = time
|
||||
}
|
||||
},
|
||||
description: customPickerDescription,
|
||||
timeUnitsLimits: customPickerTimeUnitsLimits
|
||||
)
|
||||
.onAppear {
|
||||
selectedCustomTime = selection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomTimePicker_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CustomTimePicker(
|
||||
selection: Binding.constant(300)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ struct LibraryImagePicker: View {
|
||||
@State var images: [UploadContent] = []
|
||||
|
||||
var body: some View {
|
||||
LibraryImageListPicker(images: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
|
||||
LibraryMediaListPicker(media: $images, selectionLimit: 1, didFinishPicking: didFinishPicking)
|
||||
.onChange(of: images) { _ in
|
||||
if let img = images.first {
|
||||
image = img.uiImage
|
||||
@@ -26,19 +26,19 @@ struct LibraryImagePicker: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
struct LibraryMediaListPicker: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = PHPickerViewController
|
||||
@Binding var images: [UploadContent]
|
||||
@Binding var media: [UploadContent]
|
||||
var selectionLimit: Int
|
||||
var didFinishPicking: (_ didSelectItems: Bool) -> Void
|
||||
|
||||
class Coordinator: PHPickerViewControllerDelegate {
|
||||
let parent: LibraryImageListPicker
|
||||
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryImageListPicker")
|
||||
var images: [UploadContent] = []
|
||||
var imageCount: Int = 0
|
||||
let parent: LibraryMediaListPicker
|
||||
let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker")
|
||||
var media: [UploadContent] = []
|
||||
var mediaCount: Int = 0
|
||||
|
||||
init(_ parent: LibraryImageListPicker) {
|
||||
init(_ parent: LibraryMediaListPicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
@@ -48,13 +48,23 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
return
|
||||
}
|
||||
|
||||
parent.images = []
|
||||
images = []
|
||||
imageCount = results.count
|
||||
parent.media = []
|
||||
media = []
|
||||
mediaCount = results.count
|
||||
for result in results {
|
||||
logger.log("LibraryImageListPicker result")
|
||||
logger.log("LibraryMediaListPicker result")
|
||||
let p = result.itemProvider
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
||||
if let url = url {
|
||||
let tempUrl = URL(fileURLWithPath: getTempFilesDirectory().path + "/" + generateNewFileName("video", url.pathExtension))
|
||||
if ((try? FileManager.default.copyItem(at: url, to: tempUrl)) != nil) {
|
||||
ChatModel.shared.filesToDelete.insert(tempUrl)
|
||||
self.loadVideo(url: tempUrl, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
||||
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
||||
self.loadImage(object: url, error: error)
|
||||
}
|
||||
@@ -65,14 +75,14 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatchQueue.sync { self.imageCount -= 1}
|
||||
dispatchQueue.sync { self.mediaCount -= 1}
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||
self.dispatchQueue.sync {
|
||||
if self.parent.images.count == 0 {
|
||||
logger.log("LibraryImageListPicker: added \(self.images.count) images out of \(results.count)")
|
||||
self.parent.images = self.images
|
||||
if self.parent.media.count == 0 {
|
||||
logger.log("LibraryMediaListPicker: added \(self.media.count) images out of \(results.count)")
|
||||
self.parent.media = self.media
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,19 +90,35 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
|
||||
func loadImage(object: Any?, error: Error? = nil) {
|
||||
if let error = error {
|
||||
logger.error("LibraryImageListPicker: couldn't load image with error: \(error.localizedDescription)")
|
||||
logger.error("LibraryMediaListPicker: couldn't load image with error: \(error.localizedDescription)")
|
||||
} else if let image = object as? UIImage {
|
||||
images.append(.simpleImage(image: image))
|
||||
logger.log("LibraryImageListPicker: added image")
|
||||
media.append(.simpleImage(image: image))
|
||||
logger.log("LibraryMediaListPicker: added image")
|
||||
} else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) {
|
||||
images.append(image)
|
||||
media.append(image)
|
||||
}
|
||||
dispatchQueue.sync {
|
||||
self.imageCount -= 1
|
||||
if self.imageCount == 0 && self.parent.images.count == 0 {
|
||||
logger.log("LibraryImageListPicker: added all images")
|
||||
self.parent.images = self.images
|
||||
self.images = []
|
||||
self.mediaCount -= 1
|
||||
if self.mediaCount == 0 && self.parent.media.count == 0 {
|
||||
logger.log("LibraryMediaListPicker: added all media")
|
||||
self.parent.media = self.media
|
||||
self.media = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadVideo(url: URL?, error: Error? = nil) {
|
||||
if let error = error {
|
||||
logger.error("LibraryMediaListPicker: couldn't load video with error: \(error.localizedDescription)")
|
||||
} else if let url = url as URL?, let video = UploadContent.loadVideoFromURL(url: url) {
|
||||
media.append(video)
|
||||
}
|
||||
dispatchQueue.sync {
|
||||
self.mediaCount -= 1
|
||||
if self.mediaCount == 0 && self.parent.media.count == 0 {
|
||||
logger.log("LibraryMediaListPicker: added all media")
|
||||
self.parent.media = self.media
|
||||
self.media = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,8 +130,10 @@ struct LibraryImageListPicker: UIViewControllerRepresentable {
|
||||
|
||||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
config.filter = .any(of: [.images, .videos])
|
||||
config.selectionLimit = selectionLimit
|
||||
config.selection = .ordered
|
||||
//config.preferredAssetRepresentationMode = .current
|
||||
let controller = PHPickerViewController(configuration: config)
|
||||
controller.delegate = context.coordinator
|
||||
return controller
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
import SimpleXChat
|
||||
|
||||
enum LAResult {
|
||||
case success
|
||||
@@ -25,7 +26,38 @@ func authorize(_ text: String, _ authorized: Binding<Bool>) {
|
||||
}
|
||||
}
|
||||
|
||||
func authenticate(reason: String, completed: @escaping (LAResult) -> Void) {
|
||||
struct LocalAuthRequest {
|
||||
var title: LocalizedStringKey? // if title is null, reason is shown
|
||||
var reason: String
|
||||
var password: String
|
||||
var selfDestruct: Bool
|
||||
var completed: (LAResult) -> Void
|
||||
|
||||
static var sample = LocalAuthRequest(title: "Enter Passcode", reason: "Authenticate", password: "", selfDestruct: false, completed: { _ in })
|
||||
}
|
||||
|
||||
func authenticate(title: LocalizedStringKey? = nil, reason: String, selfDestruct: Bool = false, completed: @escaping (LAResult) -> Void) {
|
||||
logger.debug("authenticate")
|
||||
switch privacyLocalAuthModeDefault.get() {
|
||||
case .system: systemAuthenticate(reason, completed)
|
||||
case .passcode:
|
||||
if let password = kcAppPassword.get() {
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.laRequest = LocalAuthRequest(
|
||||
title: title,
|
||||
reason: reason,
|
||||
password: password,
|
||||
selfDestruct: selfDestruct && UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT),
|
||||
completed: completed
|
||||
)
|
||||
}
|
||||
} else {
|
||||
completed(.unavailable(authError: NSLocalizedString("No app password", comment: "Authentication unavailable")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func systemAuthenticate(_ reason: String, _ completed: @escaping (LAResult) -> Void) {
|
||||
let laContext = LAContext()
|
||||
var authAvailabilityError: NSError?
|
||||
if laContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authAvailabilityError) {
|
||||
@@ -52,6 +84,13 @@ func laTurnedOnAlert() -> Alert {
|
||||
)
|
||||
}
|
||||
|
||||
func laPasscodeNotSetAlert() -> Alert {
|
||||
mkAlert(
|
||||
title: "SimpleX Lock not enabled!",
|
||||
message: "You can turn on SimpleX Lock via Settings."
|
||||
)
|
||||
}
|
||||
|
||||
func laFailedAlert() -> Alert {
|
||||
mkAlert(
|
||||
title: "Authentication failed",
|
||||
@@ -72,3 +111,4 @@ func laUnavailableTurningOffAlert() -> Alert {
|
||||
message: "Device authentication is disabled. Turning off SimpleX Lock."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
61
apps/ios/Shared/Views/Helpers/MailView.swift
Normal file
61
apps/ios/Shared/Views/Helpers/MailView.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// MailView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 01.05.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import MessageUI
|
||||
|
||||
struct MailView: UIViewControllerRepresentable {
|
||||
@Binding var isShowing: Bool
|
||||
@Binding var result: Result<MFMailComposeResult, Error>?
|
||||
var subject = ""
|
||||
var messageBody = ""
|
||||
|
||||
class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
|
||||
@Binding var isShowing: Bool
|
||||
@Binding var result: Result<MFMailComposeResult, Error>?
|
||||
|
||||
init(isShowing: Binding<Bool>,
|
||||
result: Binding<Result<MFMailComposeResult, Error>?>) {
|
||||
_isShowing = isShowing
|
||||
_result = result
|
||||
}
|
||||
|
||||
func mailComposeController(
|
||||
_ controller: MFMailComposeViewController,
|
||||
didFinishWith result: MFMailComposeResult,
|
||||
error: Error?
|
||||
) {
|
||||
defer {
|
||||
isShowing = false
|
||||
}
|
||||
if let error = error {
|
||||
self.result = .failure(error)
|
||||
return
|
||||
}
|
||||
self.result = .success(result)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(isShowing: $isShowing, result: $result)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: UIViewControllerRepresentableContext<MailView>) -> MFMailComposeViewController {
|
||||
let vc = MFMailComposeViewController()
|
||||
vc.setSubject(subject)
|
||||
vc.setMessageBody(messageBody, isHTML: true)
|
||||
vc.mailComposeDelegate = context.coordinator
|
||||
return vc
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: MFMailComposeViewController,
|
||||
context: UIViewControllerRepresentableContext<MailView>) {
|
||||
|
||||
}
|
||||
}
|
||||
61
apps/ios/Shared/Views/Helpers/VideoPlayerView.swift
Normal file
61
apps/ios/Shared/Views/Helpers/VideoPlayerView.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// Created by Avently on 30.03.2023.
|
||||
// Copyright (c) 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
struct VideoPlayerView: UIViewRepresentable {
|
||||
|
||||
static var players: [String: AVPlayer] = [:]
|
||||
static func getOrCreatePlayer(_ url: URL, _ gallery: Bool) -> AVPlayer {
|
||||
if let player = players[url.absoluteString + gallery.description] {
|
||||
return player
|
||||
} else {
|
||||
let player = AVPlayer(url: url)
|
||||
players[url.absoluteString + gallery.description] = player
|
||||
return player
|
||||
}
|
||||
}
|
||||
|
||||
typealias UIViewType = UIView
|
||||
let player: AVPlayer
|
||||
let url: URL
|
||||
let showControls: Bool
|
||||
|
||||
func makeUIView(context: UIViewRepresentableContext<VideoPlayerView>) -> UIView {
|
||||
let controller = AVPlayerViewController()
|
||||
controller.showsPlaybackControls = showControls
|
||||
if #available(iOS 16.0, *) {
|
||||
controller.speeds = []
|
||||
}
|
||||
controller.player = player
|
||||
context.coordinator.controller = controller
|
||||
context.coordinator.timeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||
player.seek(to: CMTime.zero)
|
||||
player.play()
|
||||
}
|
||||
return controller.view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<VideoPlayerView>) {
|
||||
}
|
||||
|
||||
func makeCoordinator() -> VideoPlayerView.Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
class Coordinator: NSObject {
|
||||
var controller: AVPlayerViewController?
|
||||
var timeObserver: Any? = nil
|
||||
|
||||
deinit {
|
||||
print("deinit coordinator of VideoPlayer")
|
||||
if let timeObserver = timeObserver {
|
||||
NotificationCenter.default.removeObserver(timeObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
Normal file
77
apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// LocalAuthView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 10/04/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct LocalAuthView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var authRequest: LocalAuthRequest
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
PasscodeView(passcode: $password, title: authRequest.title ?? "Enter Passcode", reason: authRequest.reason, submitLabel: "Submit") {
|
||||
if let sdPassword = kcSelfDestructPassword.get(), authRequest.selfDestruct && password == sdPassword {
|
||||
deleteStorageAndRestart(sdPassword) { r in
|
||||
m.laRequest = nil
|
||||
authRequest.completed(r)
|
||||
}
|
||||
return
|
||||
}
|
||||
let r: LAResult = password == authRequest.password
|
||||
? .success
|
||||
: .failed(authError: NSLocalizedString("Incorrect passcode", comment: "PIN entry"))
|
||||
m.laRequest = nil
|
||||
authRequest.completed(r)
|
||||
} cancel: {
|
||||
m.laRequest = nil
|
||||
authRequest.completed(.failed(authError: NSLocalizedString("Authentication cancelled", comment: "PIN entry")))
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteStorageAndRestart(_ password: String, completed: @escaping (LAResult) -> Void) {
|
||||
Task {
|
||||
do {
|
||||
try await stopChatAsync()
|
||||
try await deleteChatAsync()
|
||||
_ = kcAppPassword.set(password)
|
||||
_ = kcSelfDestructPassword.remove()
|
||||
await NtfManager.shared.removeAllNotifications()
|
||||
let displayName = UserDefaults.standard.string(forKey: DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME)
|
||||
UserDefaults.standard.removeObject(forKey: DEFAULT_LA_SELF_DESTRUCT)
|
||||
UserDefaults.standard.removeObject(forKey: DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME)
|
||||
await MainActor.run {
|
||||
m.chatDbChanged = true
|
||||
m.chatInitialized = false
|
||||
}
|
||||
resetChatCtrl()
|
||||
try initializeChat(start: true)
|
||||
m.chatDbChanged = false
|
||||
appStateGroupDefault.set(.active)
|
||||
if m.currentUser != nil { return }
|
||||
var profile: Profile? = nil
|
||||
if let displayName = displayName, displayName != "" {
|
||||
profile = Profile(displayName: displayName, fullName: "")
|
||||
}
|
||||
m.currentUser = try apiCreateActiveUser(profile, pastTimestamp: true)
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
try startChat()
|
||||
completed(.success)
|
||||
} catch {
|
||||
completed(.failed(authError: NSLocalizedString("Incorrect passcode", comment: "PIN entry")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalAuthView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
LocalAuthView(authRequest: LocalAuthRequest.sample)
|
||||
}
|
||||
}
|
||||
156
apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift
Normal file
156
apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// PasscodeEntry.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 10/04/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PasscodeEntry: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var width: CGFloat
|
||||
var height: CGFloat
|
||||
@Binding var password: String
|
||||
@State private var showPassword = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
passwordView()
|
||||
.padding(.bottom, 4)
|
||||
if width < height * 2 / 3 {
|
||||
verticalPasswordGrid()
|
||||
} else {
|
||||
horizontalPasswordGrid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func passwordView() -> some View {
|
||||
Text(
|
||||
password == ""
|
||||
? " "
|
||||
: splitPassword()
|
||||
)
|
||||
.font(showPassword ? .title2.monospacedDigit() : .body)
|
||||
.onTapGesture {
|
||||
showPassword = !showPassword
|
||||
}
|
||||
.frame(height: 30)
|
||||
}
|
||||
|
||||
private func splitPassword() -> String {
|
||||
let n = password.count < 8 ? 8 : 4
|
||||
return password.enumerated().reduce("") { acc, c in
|
||||
acc
|
||||
+ (showPassword ? String(c.element) : "●")
|
||||
+ ((c.offset + 1) % n == 0 ? " " : "")
|
||||
}
|
||||
}
|
||||
|
||||
private func verticalPasswordGrid() -> some View {
|
||||
let s = width / 3
|
||||
return VStack(spacing: 0) {
|
||||
digitsRow(s, 1, 2, 3)
|
||||
Divider()
|
||||
digitsRow(s, 4, 5, 6)
|
||||
Divider()
|
||||
digitsRow(s, 7, 8, 9)
|
||||
Divider()
|
||||
HStack(spacing: 0) {
|
||||
passwordEdit(s, image: "multiply") {
|
||||
password = ""
|
||||
}
|
||||
Divider()
|
||||
passwordDigit(s, 0)
|
||||
Divider()
|
||||
passwordEdit(s, image: "delete.backward") {
|
||||
if password != "" { password.removeLast() }
|
||||
}
|
||||
}
|
||||
.frame(height: s)
|
||||
}
|
||||
.frame(width: width, height: s * 4 * 0.97)
|
||||
}
|
||||
|
||||
private func horizontalPasswordGrid() -> some View {
|
||||
let s = height / 5
|
||||
return VStack(spacing: 0) {
|
||||
horizontalDigitsRow(s, 1, 2, 3) {
|
||||
passwordEdit(s, image: "multiply") {
|
||||
password = ""
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
horizontalDigitsRow(s, 4, 5, 6) {
|
||||
passwordDigit(s, 0)
|
||||
}
|
||||
Divider()
|
||||
horizontalDigitsRow(s, 7, 8, 9) {
|
||||
passwordEdit(s, image: "delete.backward") {
|
||||
if password != "" { password.removeLast() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: s * 4, height: s * 3 * 0.97)
|
||||
}
|
||||
|
||||
private func digitsRow(_ size: CGFloat, _ d1: Int, _ d2: Int, _ d3: Int) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
passwordDigit(size, d1)
|
||||
Divider()
|
||||
passwordDigit(size, d2)
|
||||
Divider()
|
||||
passwordDigit(size, d3)
|
||||
}
|
||||
.frame(height: size * 0.97)
|
||||
}
|
||||
|
||||
private func horizontalDigitsRow<V: View>(_ size: CGFloat, _ d1: Int, _ d2: Int, _ d3: Int, _ button: @escaping () -> V) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
digitsRow(size, d1, d2, d3)
|
||||
Divider()
|
||||
button()
|
||||
}
|
||||
.frame(height: size * 0.97)
|
||||
}
|
||||
|
||||
private func passwordDigit(_ size: CGFloat, _ d: Int) -> some View {
|
||||
let s = String(describing: d)
|
||||
return passwordButton(size) {
|
||||
if password.count < 16 {
|
||||
password = password + s
|
||||
}
|
||||
} label: {
|
||||
Text(s).font(.title)
|
||||
}
|
||||
.disabled(password.count >= 16)
|
||||
}
|
||||
|
||||
private func passwordEdit(_ size: CGFloat, image: String, action: @escaping () -> Void) -> some View {
|
||||
passwordButton(size, action: action) {
|
||||
Image(systemName: image)
|
||||
}
|
||||
}
|
||||
|
||||
private func passwordButton<V: View>(_ size: CGFloat, action: @escaping () -> Void, label: () -> V) -> some View {
|
||||
let h = size * 0.97
|
||||
return Button(action: action) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.frame(width: h, height: h)
|
||||
.foregroundColor(Color(uiColor: .systemBackground))
|
||||
label()
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: size, height: h)
|
||||
}
|
||||
}
|
||||
|
||||
struct PasscodeEntry_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PasscodeEntry(width: 800, height: 420, password: Binding.constant(""))
|
||||
}
|
||||
}
|
||||
92
apps/ios/Shared/Views/LocalAuth/PasscodeView.swift
Normal file
92
apps/ios/Shared/Views/LocalAuth/PasscodeView.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// PasscodeView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 11/04/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PasscodeView: View {
|
||||
@Binding var passcode: String
|
||||
var title: LocalizedStringKey
|
||||
var reason: String? = nil
|
||||
var submitLabel: LocalizedStringKey
|
||||
var submitEnabled: ((String) -> Bool)?
|
||||
var submit: () -> Void
|
||||
var cancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
if g.size.width < g.size.height * 2 / 3 {
|
||||
verticalPasscodeView(g)
|
||||
} else {
|
||||
horizontalPasscodeView(g)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
}
|
||||
|
||||
private func verticalPasscodeView(_ g: GeometryProxy) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
passcodeEntry(g)
|
||||
Spacer()
|
||||
HStack(spacing: 48) {
|
||||
buttonsView()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 32)
|
||||
}
|
||||
|
||||
private func horizontalPasscodeView(_ g: GeometryProxy) -> some View {
|
||||
HStack(alignment: .bottom, spacing: 48) {
|
||||
VStack(spacing: 8) {
|
||||
passcodeEntry(g)
|
||||
}
|
||||
VStack(spacing: 48) {
|
||||
buttonsView()
|
||||
}
|
||||
.frame(maxHeight: g.size.height / 5 * 3 * 0.97)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
|
||||
@ViewBuilder private func passcodeEntry(_ g: GeometryProxy) -> some View {
|
||||
Text(title)
|
||||
.font(.title)
|
||||
.bold()
|
||||
.padding(.top, 8)
|
||||
if let reason = reason {
|
||||
Text(reason).padding(.top, 4)
|
||||
}
|
||||
Spacer()
|
||||
PasscodeEntry(width: g.size.width, height: g.size.height, password: $passcode)
|
||||
}
|
||||
|
||||
@ViewBuilder private func buttonsView() -> some View {
|
||||
Button(action: cancel) {
|
||||
Label("Cancel", systemImage: "multiply")
|
||||
}
|
||||
Button(action: submit) {
|
||||
Label(submitLabel, systemImage: "checkmark")
|
||||
}
|
||||
.disabled(submitEnabled?(passcode) == false || passcode.count < 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct PasscodeViewView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PasscodeView(
|
||||
passcode: Binding.constant(""),
|
||||
title: "Enter Passcode",
|
||||
reason: "Unlock app",
|
||||
submitLabel: "Submit",
|
||||
submit: {},
|
||||
cancel: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
68
apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift
Normal file
68
apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// SetAppPaswordView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 10/04/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct SetAppPasscodeView: View {
|
||||
var passcodeKeychain: KeyChainItem = kcAppPassword
|
||||
var title: LocalizedStringKey = "New Passcode"
|
||||
var reason: String?
|
||||
var submit: () -> Void
|
||||
var cancel: () -> Void
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State private var showKeychainError = false
|
||||
@State private var passcode = ""
|
||||
@State private var enteredPassword = ""
|
||||
@State private var confirming = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if confirming {
|
||||
setPasswordView(
|
||||
title: "Confirm Passcode",
|
||||
submitLabel: "Confirm",
|
||||
submitEnabled: { pwd in pwd == enteredPassword }
|
||||
) {
|
||||
if passcode == enteredPassword {
|
||||
if passcodeKeychain.set(passcode) {
|
||||
enteredPassword = ""
|
||||
passcode = ""
|
||||
dismiss()
|
||||
submit()
|
||||
} else {
|
||||
showKeychainError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setPasswordView(title: title, submitLabel: "Save") {
|
||||
enteredPassword = passcode
|
||||
passcode = ""
|
||||
confirming = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showKeychainError) {
|
||||
mkAlert(title: "KeyChain error", message: "Error saving passcode")
|
||||
}
|
||||
}
|
||||
|
||||
private func setPasswordView(title: LocalizedStringKey, submitLabel: LocalizedStringKey, submitEnabled: (((String) -> Bool))? = nil, submit: @escaping () -> Void) -> some View {
|
||||
PasscodeView(passcode: $passcode, title: title, reason: reason, submitLabel: submitLabel, submitEnabled: submitEnabled, submit: submit) {
|
||||
dismiss()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SetAppPasscodeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SetAppPasscodeView(submit: {}, cancel: {})
|
||||
}
|
||||
}
|
||||
28
apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift
Normal file
28
apps/ios/Shared/Views/NewChat/AddContactLearnMore.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// AddContactLearnMore.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 27.04.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddContactLearnMore: View {
|
||||
var body: some View {
|
||||
List {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("To connect, your contact can scan QR code or use the link in the app.")
|
||||
Text("If you can't meet in person, show QR code in a video call, or share the link.")
|
||||
Text("Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddContactLearnMore_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddContactLearnMore()
|
||||
}
|
||||
}
|
||||
@@ -16,50 +16,73 @@ struct AddContactView: View {
|
||||
var connReqInvitation: String
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Your contact can scan it from the app.")
|
||||
.padding(.bottom, 4)
|
||||
if (contactConnection?.incognito ?? chatModel.incognito) {
|
||||
HStack {
|
||||
Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("A random profile will be sent to your contact").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Your chat profile will be sent to your contact").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
if connReqInvitation != "" {
|
||||
QRCode(uri: connReqInvitation).padding(.bottom)
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
Text("If you can't meet in person, **show QR code in the video call**, or share the link.")
|
||||
.padding(.bottom)
|
||||
Button {
|
||||
showShareSheet(items: [connReqInvitation])
|
||||
} label: {
|
||||
Label("Share invitation link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
List {
|
||||
OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section("1-time link") {
|
||||
oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInvitation)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.onAppear { chatModel.connReqInv = connReqInvitation }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func oneTimeLinkSection(contactConnection: PendingContactConnection? = nil, connReqInvitation: String) -> some View {
|
||||
if connReqInvitation != "" {
|
||||
QRCode(uri: connReqInvitation)
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
shareLinkButton(connReqInvitation)
|
||||
oneTimeLinkLearnMoreButton()
|
||||
}
|
||||
|
||||
private func shareLinkButton(_ connReqInvitation: String) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [connReqInvitation])
|
||||
} label: {
|
||||
settingsRow("square.and.arrow.up") {
|
||||
Text("Share 1-time link")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func oneTimeLinkLearnMoreButton() -> some View {
|
||||
NavigationLink {
|
||||
AddContactLearnMore()
|
||||
.navigationTitle("One-time invitation link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("info.circle") {
|
||||
Text("Learn more")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OneTimeLinkProfileText: View {
|
||||
@EnvironmentObject private var chatModel: ChatModel
|
||||
var contactConnection: PendingContactConnection? = nil
|
||||
var connReqInvitation: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if (contactConnection?.incognito ?? chatModel.incognito) {
|
||||
Image(systemName: "theatermasks").foregroundColor(.indigo)
|
||||
Text("A random profile will be sent to your contact")
|
||||
} else {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary)
|
||||
Text("Your chat profile will be sent to your contact")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddContactView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AddContactView(connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D")
|
||||
|
||||
@@ -24,14 +24,14 @@ struct AddGroupView: View {
|
||||
|
||||
var body: some View {
|
||||
if let chat = chat, let groupInfo = groupInfo {
|
||||
AddGroupMembersView(
|
||||
AddGroupMembersViewCommon(
|
||||
chat: chat,
|
||||
groupInfo: groupInfo,
|
||||
creatingGroup: true,
|
||||
showFooterCounter: false
|
||||
) { _ in
|
||||
dismiss()
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user