Compare commits
592 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6b7d8353c | |||
| 113f0cce6a | |||
| ffd0de57d5 | |||
| b24d4980b6 | |||
| 141f3c8fe5 | |||
| b5d8c65249 | |||
| de94892fe7 | |||
| b7131e16f2 | |||
| 11a44dc1fd | |||
| 0af718f03f | |||
| f8f5c3c6be | |||
| 13912a4af9 | |||
| 601b4cd619 | |||
| d2b4b7bed6 | |||
| fa95e4e9ad | |||
| 88fdc1ef75 | |||
| 7ab6e44a6e | |||
| 26986686ca | |||
| e76dc33cf0 | |||
| aa2eafdacb | |||
| 2c3c97f5cc | |||
| 83f42704ea | |||
| 7ff6ef09fe | |||
| dbe4504f05 | |||
| 9a87f344b5 | |||
| 2127c7dcce | |||
| e3528d3ffe | |||
| ec014d721e | |||
| 75bacb7923 | |||
| 0d8c179861 | |||
| e9a99dfb3c | |||
| baa585357f | |||
| cebb4aa93b | |||
| df53ae9d4f | |||
| 21b1904b0e | |||
| b1ef442f1e | |||
| 4020cb074f | |||
| 0c69f6553a | |||
| 03865b4a18 | |||
| ac5f0bc7bb | |||
| 12423f4afa | |||
| fabbe0285d | |||
| b01efd9d0a | |||
| fc28ff0f15 | |||
| 213f6cf372 | |||
| a764e1906d | |||
| 54b40a5838 | |||
| 35fc0544a0 | |||
| 6907f02ea6 | |||
| c6ece8afdd | |||
| a00e5901de | |||
| ad2a0024f2 | |||
| c5261a416f | |||
| 193e17f7af | |||
| 87fd642951 | |||
| fb044000d2 | |||
| 69791dbdcf | |||
| dc11202250 | |||
| 96ac0a7715 | |||
| bdb6bd6e20 | |||
| 7ccd80bf23 | |||
| f0d6f15393 | |||
| 49c91283d8 | |||
| 55de5b9ce9 | |||
| 8727d3b91b | |||
| bb2a6ec65d | |||
| 6118fca000 | |||
| 026a8022e0 | |||
| dc1106afad | |||
| cc9b4f3bb3 | |||
| 533d0e40ac | |||
| d9ad755474 | |||
| 15ca662805 | |||
| ab034e626f | |||
| fc83bc692a | |||
| d20d444e6e | |||
| fc0879ebb7 | |||
| f048ddb922 | |||
| 0e39a62ab1 | |||
| 53f0fe9ca4 | |||
| 95c1d8d798 | |||
| 4a39b481b1 | |||
| 65c7ecbddf | |||
| 67472b6285 | |||
| 9199fbffd5 | |||
| 6e5eb697a2 | |||
| e2e6935e5b | |||
| 769ef25c31 | |||
| 2f730d54e9 | |||
| fe0013c4a9 | |||
| 5261886b31 | |||
| 93ab3076d4 | |||
| 3b88ddbd4f | |||
| 54ff95f350 | |||
| d6dc35738e | |||
| 5b3aba9db2 | |||
| 4526afe7e9 | |||
| c67302a5bb | |||
| 3685c85743 | |||
| 0f301adc57 | |||
| 1f226dda64 | |||
| 981cbb8bf9 | |||
| d5507f2fa3 | |||
| 55d180466a | |||
| 8a906485d1 | |||
| e79fa136a4 | |||
| 560b521673 | |||
| c849f5356d | |||
| 33e12e35a0 | |||
| 8a70bad9af | |||
| efb7fc6c3b | |||
| 630fea42c3 | |||
| 68e570656d | |||
| 3cbb2c2d71 | |||
| 5ca27f63e6 | |||
| 63d1b2060e | |||
| 25fb099c44 | |||
| d69e222b7b | |||
| f7cb6f2796 | |||
| c5813b3489 | |||
| 10ded1530c | |||
| 255538e5d7 | |||
| acc9be1a5b | |||
| d47ff3597d | |||
| 529921e16a | |||
| 0a4667304c | |||
| 17a0f3a210 | |||
| c13e8e4037 | |||
| 3a61290730 | |||
| 166082c021 | |||
| 69bbe0ae91 | |||
| 0d9ef4e567 | |||
| 17b55c51c5 | |||
| 665d9dcd00 | |||
| c13c7baaaf | |||
| af993529f9 | |||
| 05aab35a1f | |||
| 40e93cc61e | |||
| ea320f531f | |||
| 4f99075b14 | |||
| de7882c904 | |||
| 0cad378bc5 | |||
| da058ca376 | |||
| c22d23750f | |||
| b5f91f2d31 | |||
| c6ab8ec6b3 | |||
| 4447b66b4e | |||
| 2539255957 | |||
| 4cfda91124 | |||
| 7aec147cec | |||
| f6f2044675 | |||
| 5f0b5c5a9f | |||
| dfdb4af646 | |||
| 2ab5f14119 | |||
| 46d774a822 | |||
| acf2f1fbbe | |||
| 1a853d4eea | |||
| 3ea8279451 | |||
| 87c55dbbf7 | |||
| fb4475027d | |||
| 388609563d | |||
| 0cb568d206 | |||
| 8f6e9741e7 | |||
| e00001b571 | |||
| 351cfcbcbc | |||
| 74b837bf9a | |||
| 691cd489ea | |||
| 5ed701402b | |||
| 1839dab17b | |||
| 06939343a1 | |||
| b2ebb81fcf | |||
| bccbb9900f | |||
| 1f3355921c | |||
| 9e45f68a6e | |||
| ebb5d629e7 | |||
| e002f33c53 | |||
| 71bea947a5 | |||
| 90d5abdff1 | |||
| 6407d5de63 | |||
| 9cba96082d | |||
| 2089fd8539 | |||
| 31c4ff2705 | |||
| 33895b0330 | |||
| fe20a43232 | |||
| 087519c39d | |||
| 3b49817f17 | |||
| f94f0dea08 | |||
| d68a3ba80d | |||
| 7a4dca1e4c | |||
| fff29f8548 | |||
| 8cc03b6c21 | |||
| 7a5b04d523 | |||
| d7ab0aef14 | |||
| 41cb734d56 | |||
| 6adf8f29b0 | |||
| 4ca1b57e1b | |||
| 9432a5e5cd | |||
| a9ec1f9ec1 | |||
| 122387d180 | |||
| 23f54c1022 | |||
| 0b0b78293f | |||
| eef1e97ecc | |||
| 564b137f95 | |||
| 6edea46dad | |||
| 2fe3acf4df | |||
| 1c64b17545 | |||
| 700918f0ca | |||
| 94c552ca12 | |||
| dfe16991d0 | |||
| acb372a4ce | |||
| 121eaf6073 | |||
| 8cc075eda8 | |||
| 05e7f35037 | |||
| e582d2d742 | |||
| 043a4ed915 | |||
| 76cb9013f5 | |||
| f1e8c65aa1 | |||
| 8a6bf05773 | |||
| 0118e64ab4 | |||
| 0477b1aad3 | |||
| ae850c8ce8 | |||
| 94ae571ec3 | |||
| 4552860345 | |||
| a1579810bb | |||
| 4574198990 | |||
| 4d18174b11 | |||
| defd095a4f | |||
| ed60f28e56 | |||
| f0b889ffcf | |||
| c07df9e05f | |||
| efe8ed1739 | |||
| bcd50019be | |||
| 7b48c59f9f | |||
| 04033fc0b5 | |||
| 7b90e01b3a | |||
| ef1897f865 | |||
| f587179045 | |||
| 791489e943 | |||
| c485837910 | |||
| a95415fa1a | |||
| 8d48c4b14c | |||
| 9f44242e4c | |||
| 7e7d93c596 | |||
| 073818db55 | |||
| 519dd9e219 | |||
| 94218a1a7e | |||
| 996c6efddd | |||
| fd9c080103 | |||
| 5cb8badb22 | |||
| 0438f35539 | |||
| d5eb7b7811 | |||
| e04f74738e | |||
| 5f0ccb9f17 | |||
| b2d18f6960 | |||
| b52dfee078 | |||
| 70991debfd | |||
| 885aa9cfa5 | |||
| 75a468434c | |||
| 3b98032371 | |||
| 3a0921c093 | |||
| 3740805125 | |||
| b0e0b0beb8 | |||
| c823a4fa6c | |||
| 3c694e2841 | |||
| c159c2ede3 | |||
| 1d0d7bbd01 | |||
| 82c4d77c73 | |||
| 25938af62f | |||
| edfcced1fa | |||
| 5bff5a855e | |||
| 46a60d979b | |||
| f6ef57534f | |||
| 1e4479f736 | |||
| 789c762c81 | |||
| e9baeba31f | |||
| 2d5bbcdd61 | |||
| c3f67aff69 | |||
| 7cb3a499b2 | |||
| 32e7fd72d3 | |||
| 915bbed400 | |||
| 2ae5a8bffd | |||
| cd1550a14d | |||
| c72c461306 | |||
| 38fa4c231f | |||
| cb683d0706 | |||
| 84ae39b012 | |||
| 6a12f2dec8 | |||
| d1f704d160 | |||
| 9871ebb3b1 | |||
| 02c404593c | |||
| 1d9c5b7a0b | |||
| e20a4e54c3 | |||
| 1f8c69ec23 | |||
| 35c37263b8 | |||
| 9b70599cc5 | |||
| a4a11f99d2 | |||
| 7c8955fcdb | |||
| f922064f5c | |||
| 110839627f | |||
| c90b71120f | |||
| 3ec029e489 | |||
| f4c799aced | |||
| d970470702 | |||
| 9ee74bd36e | |||
| 0a4ff2e35f | |||
| a3550df893 | |||
| 3778698a6e | |||
| cf7a16e857 | |||
| bef1597fa1 | |||
| f06118835d | |||
| fa9e50a904 | |||
| 932a65630c | |||
| b649b64999 | |||
| e94ead3a4f | |||
| 42d7c20a47 | |||
| cf8fd2ea11 | |||
| 5006742577 | |||
| 38d46891a1 | |||
| 6a7e573b42 | |||
| b31a74567d | |||
| b3f0e71ded | |||
| ed50623ef5 | |||
| 53368713a0 | |||
| 5739b94808 | |||
| 0782408682 | |||
| 41576f80e7 | |||
| 2503a86f07 | |||
| dc713268e8 | |||
| d0c52e43a2 | |||
| 2e8e6cef7e | |||
| 239c815f3e | |||
| 1a6245fe51 | |||
| b3d15f97f0 | |||
| c0b8cfb3e2 | |||
| 818161c6ed | |||
| 4c53620dfa | |||
| ea5afb28d3 | |||
| 060760675d | |||
| 75101ac885 | |||
| 159f66e812 | |||
| 6d19b48979 | |||
| ce73a15787 | |||
| e6545a1747 | |||
| 601b081cab | |||
| d31dedf132 | |||
| 7441ed9892 | |||
| f6ee6338c4 | |||
| 5a42c0c1d2 | |||
| 240131a023 | |||
| 37e275c3ca | |||
| f893ad15de | |||
| a0763b3a43 | |||
| e6ba82c8ff | |||
| a8bdf1555f | |||
| d278b3dbcd | |||
| ac4b1e9406 | |||
| b04f159970 | |||
| 55331289d3 | |||
| b2b1519aea | |||
| e769abf14a | |||
| 9ca7031f82 | |||
| 7a418918d6 | |||
| 8f1302e1c6 | |||
| ee7c5d2aac | |||
| bf697c722a | |||
| f30dfa0be7 | |||
| 9c73ef9769 | |||
| e38db7fb44 | |||
| cb76c8079c | |||
| 8fbba16f53 | |||
| 5384e2826d | |||
| 0975079a93 | |||
| 74e2b7582e | |||
| 413a8f6b76 | |||
| 619cff91b2 | |||
| 2ee16f9398 | |||
| bfab2d9fb6 | |||
| 229ea80499 | |||
| 95de471754 | |||
| cbc86cd81e | |||
| 19cab39ee8 | |||
| 6fa3695ad6 | |||
| 6e6afdbd25 | |||
| 93e88c3953 | |||
| 676b533393 | |||
| e1fb0ac0b1 | |||
| 8bda64a5c1 | |||
| ab058d7222 | |||
| 4f9c53f561 | |||
| 5257c6f9ca | |||
| 6729b1fb4f | |||
| ac5351a752 | |||
| 2ff4619ca4 | |||
| 493ad14b39 | |||
| 7f08f87ee4 | |||
| ce1b66cef2 | |||
| 3e18fdc34d | |||
| c09075d71e | |||
| 41b4d7851a | |||
| 92bec5eabb | |||
| 24587ecc92 | |||
| 6865515f43 | |||
| 5ee6f40e75 | |||
| 637189cc2d | |||
| 13bfc9e92b | |||
| 032c5d3a5b | |||
| a53333be20 | |||
| a966f6b19d | |||
| 70d577260b | |||
| ea12982788 | |||
| 6fca6c22c5 | |||
| e343dd017b | |||
| 70a94d772a | |||
| 2689d1e27b | |||
| 4a9b54fbaf | |||
| f10a0ce58e | |||
| 6d488ba489 | |||
| 95776e0951 | |||
| bf9cdf3053 | |||
| fa73e63a79 | |||
| bfab76ed90 | |||
| 905295ee5f | |||
| a1e707ac1b | |||
| fea51b75e9 | |||
| ff8bbf11e7 | |||
| 391e9d57f2 | |||
| dae9f8575d | |||
| 065d9be614 | |||
| 4251762553 | |||
| c7017b3efc | |||
| b4ae3b9431 | |||
| 7138b09bae | |||
| bf59402856 | |||
| c6e5d6e2b8 | |||
| 0847b725b3 | |||
| 0d6f43a5ac | |||
| 5a014ba410 | |||
| 99888f9d5f | |||
| 670bf34ff5 | |||
| 3e873fcb32 | |||
| 23f24b1677 | |||
| 17526fa385 | |||
| 70204e071d | |||
| b348979b32 | |||
| 71ad8f2fd1 | |||
| 2dff94cbb4 | |||
| a73abfe642 | |||
| 859fa0bc22 | |||
| 41c4f13939 | |||
| f84ac713d7 | |||
| f41c04735b | |||
| a8da9b9cd9 | |||
| 64a0f509f7 | |||
| c4f8a50f0d | |||
| 93a4c0854e | |||
| 49c29c74df | |||
| e6ee5df158 | |||
| f4be0278b6 | |||
| a9d2535292 | |||
| 3e623684bc | |||
| fd90b47194 | |||
| acd3467d10 | |||
| 71ce598355 | |||
| 63393eaf0b | |||
| f90de83215 | |||
| 1c10209a31 | |||
| 5d7abf31ce | |||
| 44c0861fe4 | |||
| 1e6dc8002c | |||
| 0509ff9a1b | |||
| f12e504828 | |||
| 6a273677f1 | |||
| f5eea018d9 | |||
| 5f96f6c117 | |||
| e963ef53e7 | |||
| 1180edf80e | |||
| 4bd6517d19 | |||
| 2585f4ecfd | |||
| e22e01acd2 | |||
| 8e4299afb6 | |||
| ecff3c6ee5 | |||
| 85af368371 | |||
| 0d3928bd51 | |||
| ddeaa1c7c3 | |||
| 00ba468898 | |||
| 593c7d247c | |||
| 5907d8bd0c | |||
| 231082860e | |||
| c6366cb269 | |||
| d066c66282 | |||
| fab3827697 | |||
| d8126e0cd0 | |||
| 5baf8789d6 | |||
| dd5454f390 | |||
| fca098f535 | |||
| fba0478e50 | |||
| d951003191 | |||
| 1af513c548 | |||
| 85dc467f74 | |||
| 89e65c2fc1 | |||
| 0fa8d77214 | |||
| 6ba82c8c39 | |||
| e19c14b249 | |||
| d993381d31 | |||
| 0198c1f110 | |||
| 6c4c9b172c | |||
| d9a61af401 | |||
| a58522ef15 | |||
| bcd510c913 | |||
| c4083e00c5 | |||
| 7cd35b9068 | |||
| d3274b28ba | |||
| 40bd5dc2da | |||
| 1573b7af13 | |||
| a5bf3cbca8 | |||
| d5529031ee | |||
| d244338b13 | |||
| ce55c84b96 | |||
| c52f9baefa | |||
| ea1c5da65d | |||
| 5695bb9d46 | |||
| 13751d5705 | |||
| 02803b1491 | |||
| 222af69a71 | |||
| 5e96e1ea2b | |||
| 490e8cead8 | |||
| f578ee843b | |||
| cf2eb76cb7 | |||
| 48f9dcab9a | |||
| ac2ef54838 | |||
| 7e269c6b8c | |||
| 3e739924bb | |||
| 83706b19da | |||
| 6f19a7a20f | |||
| a8740cee4b | |||
| 4000fe383d | |||
| 75445e604e | |||
| 0596278e50 | |||
| 7a77775a42 | |||
| dc8d08ec04 | |||
| 4ad0e0f65e | |||
| d01515e755 | |||
| 3fa70ba217 | |||
| 6ae4cddc2e | |||
| ac8ee971f5 | |||
| 11d4872186 | |||
| 370e2e87ad | |||
| ef30fcf5e2 | |||
| 6ad1423f29 | |||
| c52e5349db | |||
| 3c98c6acc0 | |||
| c251f5e2bd | |||
| b165603136 | |||
| 184a24b70e | |||
| d46bae6498 | |||
| 5ac521f6d8 | |||
| 7b5b747b19 | |||
| 037655e30f | |||
| 54088c6d5a | |||
| 78f7fcc89f | |||
| b402caeb89 | |||
| ff8a8de3c8 | |||
| d22c0c2614 | |||
| 73c8d3c2e3 | |||
| 0110385e65 | |||
| ad0a678c5f | |||
| 0495a4cd78 | |||
| 02267c5263 | |||
| cad3bc048f | |||
| 1b04423745 | |||
| 2143eb2d7a | |||
| 3d395b0e45 | |||
| d79b4dfb5a | |||
| 0ae9f5ebba | |||
| 57c80786dc | |||
| da1cad96fc | |||
| b58ccedf23 | |||
| 01cadefde7 | |||
| c70e7223d9 | |||
| a1d4f52185 | |||
| 373070f388 | |||
| a03ccd0ba6 | |||
| a7982cccf9 | |||
| dccd6631af | |||
| b0516b6440 | |||
| bf91666f07 | |||
| ab47a5a27e | |||
| 23581f59ae | |||
| 291df6e9d0 | |||
| dae0b63c22 | |||
| 0964982782 | |||
| 9eb6034e1a |
@@ -5,12 +5,22 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- stable
|
- stable
|
||||||
- users
|
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
- "!*-fdroid"
|
- "!*-fdroid"
|
||||||
- "!*-armv7a"
|
- "!*-armv7a"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- "apps/ios"
|
||||||
|
- "apps/multiplatform"
|
||||||
|
- "blog"
|
||||||
|
- "docs"
|
||||||
|
- "fastlane"
|
||||||
|
- "images"
|
||||||
|
- "packages"
|
||||||
|
- "website"
|
||||||
|
- "README.md"
|
||||||
|
- "PRIVACY.md"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-release:
|
prepare-release:
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ website/package/generated*
|
|||||||
# Ignore build tool output, e.g. code coverage
|
# Ignore build tool output, e.g. code coverage
|
||||||
website/.nyc_output/
|
website/.nyc_output/
|
||||||
website/coverage/
|
website/coverage/
|
||||||
|
result
|
||||||
|
|
||||||
# Ignore API documentation
|
# Ignore API documentation
|
||||||
website/api-docs/
|
website/api-docs/
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ While SimpleX Chat Ltd is not a communication service provider, and provide publ
|
|||||||
|
|
||||||
We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
|
We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
|
||||||
|
|
||||||
SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||||
|
|
||||||
|
The cryptographic review of SimpleX protocols design was done in July 2024 by Trail of Bits – see [the announcement](/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
|
||||||
|
|
||||||
### Your information
|
### Your information
|
||||||
|
|
||||||
@@ -172,4 +174,4 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
|
|||||||
|
|
||||||
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
|
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
|
||||||
|
|
||||||
Updated April 24, 2024
|
Updated October 14, 2024
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
|
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
|
||||||
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||||
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||||
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
|
||||||
|
|
||||||
[Learn more about SimpleX Chat](#contents).
|
[Learn more about SimpleX Chat](#contents).
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ You must:
|
|||||||
|
|
||||||
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
|
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 users group](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
|
You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
|
||||||
|
|
||||||
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform:
|
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform:
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=s
|
|||||||
|
|
||||||
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:
|
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-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).
|
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmfiivxDKWFuowXrQOp11jsY8TuP__rBL%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAiz3pKNwvKudckFYMUfgoT0s96B0jfZ7ALHAu7rtE9HQ%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22jZeJpXGrRXQJU_-MSJ_v2A%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FJ5ES83pJimY2BRklS8fvy_iQwIU37xra%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0F0STP6UqN_12_k2cjjTrIjFgBGeWhOAmbY1qlk3pnM%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22VmUU0fqmYdCRmVCyvStvHA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FxCHBE_6PBRMqNEpm4UQDHXb9cz-mN7dd%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAetqlcM7zTCRw-iatnwCrvpJSto7lq5Yv6AsBMWv7GSM%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22foO5Xw4hhjOa_x7zET7otw%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FVXQTB0J2lLjYkgjWByhl6-1qmb5fgZHh%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAI6JaEWezfSwvcoTEkk6au-gkjrXR2ew2OqZYMYBvayk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22ORH9OEe8Duissh-hslfeVg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FqpHu0psOUdYfc11yQCzSyq5JhijrBzZT%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEACZ_7fbwlM45wl6cGif8cY47oPQ_AMdP0ATqOYLA6zHY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%229uRQRTir3ealdcSfB0zsrw%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.
|
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ We would love to have you join the development! You can help us with:
|
|||||||
- contributing to SimpleX Chat knowledge-base.
|
- contributing to SimpleX Chat knowledge-base.
|
||||||
- developing features - please connect to us via chat so we can help you get started.
|
- developing features - please connect to us via chat so we can help you get started.
|
||||||
|
|
||||||
## Help us with donations
|
## Please support us with your donations
|
||||||
|
|
||||||
Huge thank you to everybody who donated to SimpleX Chat!
|
Huge thank you to everybody who donated to SimpleX Chat!
|
||||||
|
|
||||||
@@ -158,20 +158,19 @@ We are prioritizing users privacy and security - it would be impossible without
|
|||||||
|
|
||||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||||
|
|
||||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||||
|
|
||||||
It is possible to donate via:
|
It is possible to donate via:
|
||||||
|
|
||||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
|
||||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad
|
||||||
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||||
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
|
||||||
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||||
- USDT:
|
- USDT:
|
||||||
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||||
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
|
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
|
||||||
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
- please ask if you want to donate any other coins.
|
||||||
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
|
||||||
|
|
||||||
Thank you,
|
Thank you,
|
||||||
|
|
||||||
@@ -234,9 +233,11 @@ You can use SimpleX with your own servers and still communicate with people usin
|
|||||||
|
|
||||||
Recent and important updates:
|
Recent and important updates:
|
||||||
|
|
||||||
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
|
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
|
||||||
|
|
||||||
[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md)
|
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
|
||||||
|
|
||||||
|
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
|
||||||
|
|
||||||
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
|
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
|
||||||
|
|
||||||
@@ -246,10 +247,6 @@ Recent and important updates:
|
|||||||
|
|
||||||
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
|
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
|
||||||
|
|
||||||
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
|
||||||
|
|
||||||
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
|
|
||||||
|
|
||||||
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
[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 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).
|
[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).
|
||||||
@@ -297,25 +294,27 @@ 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](./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.
|
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.
|
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.
|
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).
|
4. [Post-quantum resistant key exchange](./docs/GLOSSARY.md#post-quantum-cryptography) in double ratchet protocol *on every ratchet step*. Read more in [this post](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) and also see this [publication by Apple]( https://security.apple.com/blog/imessage-pq3/) explaining the need for post-quantum key rotation.
|
||||||
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
|
5. 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).
|
||||||
6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
6. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
|
||||||
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
7. 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.
|
||||||
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.
|
8. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
||||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
9. 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.
|
||||||
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
10. To protect your IP address from unknown messaging relays, and for per-message transport anonymity (compared with Tor/VPN per-connection anonymity), from v6.0 all SimpleX Chat clients use private message routing by default. Read more in [this post](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md#private-message-routing).
|
||||||
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
11. To protect your IP address from unknown file relays, when SOCKS proxy is not enabled SimpleX Chat clients ask for a confirmation before downloading the files from unknown servers.
|
||||||
12. Manual messaging queue rotations to move conversation to another SMP relay.
|
12. To protect your IP address from known servers all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||||
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
13. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
||||||
14. Local files encryption.
|
14. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
||||||
|
15. Manual messaging queue rotations to move conversation to another SMP relay.
|
||||||
|
16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
||||||
|
17. Local files encryption.
|
||||||
|
|
||||||
We plan to add:
|
We plan to add:
|
||||||
|
|
||||||
1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
|
1. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||||
2. Post-quantum resistant key exchange in double ratchet protocol.
|
2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||||
3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
3. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
|
||||||
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
|
||||||
5. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
|
|
||||||
|
|
||||||
## For developers
|
## For developers
|
||||||
|
|
||||||
@@ -382,10 +381,10 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
|||||||
- ✅ Private notes.
|
- ✅ Private notes.
|
||||||
- ✅ Improve sending videos (including encryption of locally stored videos).
|
- ✅ Improve sending videos (including encryption of locally stored videos).
|
||||||
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
|
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
|
||||||
|
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||||
- 🏗 Improve stability and reduce battery usage.
|
- 🏗 Improve stability and reduce battery usage.
|
||||||
- 🏗 Improve experience for the new users.
|
- 🏗 Improve experience for the new users.
|
||||||
- 🏗 Large groups, communities and public channels.
|
- 🏗 Large groups, communities and public channels.
|
||||||
- 🏗 Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
|
||||||
- Privacy & security slider - a simple way to set all settings at once.
|
- Privacy & security slider - a simple way to set all settings at once.
|
||||||
- SMP queue redundancy and rotation (manual is supported).
|
- SMP queue redundancy and rotation (manual is supported).
|
||||||
- Include optional message into connection request sent via contact address.
|
- Include optional message into connection request sent via contact address.
|
||||||
@@ -404,7 +403,9 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
|||||||
|
|
||||||
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
|
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
|
||||||
|
|
||||||
The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about) – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||||
|
|
||||||
|
The cryptographic review of SimpleX protocols was done in July 2024 by Trail of Bits – see [the announcement](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
|
||||||
|
|
||||||
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
|
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
|
||||||
|
|
||||||
|
|||||||
@@ -9,39 +9,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||||
application.registerForRemoteNotifications()
|
application.registerForRemoteNotifications()
|
||||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
|
||||||
removePasscodesIfReinstalled()
|
removePasscodesIfReinstalled()
|
||||||
|
prepareForLaunch()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
private func trackKeyboard() {
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
@objc func keyboardWillShow(_ notification: Notification) {
|
|
||||||
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
|
||||||
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
@objc func keyboardWillHide(_ notification: Notification) {
|
|
||||||
ChatModel.shared.keyboardHeight = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func pasteboardChanged() {
|
|
||||||
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
|
||||||
}
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||||
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
|
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
|
||||||
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
|
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
|
||||||
@@ -141,6 +119,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func prepareForLaunch() {
|
||||||
|
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
static func keepScreenOn(_ on: Bool) {
|
static func keepScreenOn(_ on: Bool) {
|
||||||
UIApplication.shared.isIdleTimerDisabled = on
|
UIApplication.shared.isIdleTimerDisabled = on
|
||||||
}
|
}
|
||||||
@@ -148,13 +130,79 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
|
|
||||||
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
static var windowStatic: UIWindow?
|
||||||
var windowScene: UIWindowScene?
|
var windowScene: UIWindowScene?
|
||||||
|
|
||||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
|
UITableView.appearance().backgroundColor = .clear
|
||||||
guard let windowScene = scene as? UIWindowScene else { return }
|
guard let windowScene = scene as? UIWindowScene else { return }
|
||||||
self.windowScene = windowScene
|
self.windowScene = windowScene
|
||||||
window = windowScene.keyWindow
|
window = windowScene.keyWindow
|
||||||
window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
|
SceneDelegate.windowStatic = windowScene.keyWindow
|
||||||
window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
|
migrateAccentColorAndTheme()
|
||||||
|
ThemeManager.applyTheme(currentThemeDefault.get())
|
||||||
|
ThemeManager.adjustWindowStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func migrateAccentColorAndTheme() {
|
||||||
|
let defs = UserDefaults.standard
|
||||||
|
/// For checking migration
|
||||||
|
// themeOverridesDefault.set([])
|
||||||
|
// currentThemeDefault.set(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
// defs.set(0.5, forKey: DEFAULT_ACCENT_COLOR_RED)
|
||||||
|
// defs.set(0.3, forKey: DEFAULT_ACCENT_COLOR_GREEN)
|
||||||
|
// defs.set(0.8, forKey: DEFAULT_ACCENT_COLOR_BLUE)
|
||||||
|
|
||||||
|
let userInterfaceStyle = getUserInterfaceStyleDefault()
|
||||||
|
if defs.double(forKey: DEFAULT_ACCENT_COLOR_GREEN) == 0 && userInterfaceStyle == .unspecified {
|
||||||
|
// No migration needed or already migrated
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultAccentColor = Color(cgColor: CGColor(red: 0.000, green: 0.533, blue: 1.000, alpha: 1))
|
||||||
|
let accentColor = Color(cgColor: getUIAccentColorDefault())
|
||||||
|
if accentColor != defaultAccentColor {
|
||||||
|
let colors = ThemeColors(primary: accentColor.toReadableHex())
|
||||||
|
var overrides = themeOverridesDefault.get()
|
||||||
|
var themeIds = currentThemeIdsDefault.get()
|
||||||
|
switch userInterfaceStyle {
|
||||||
|
case .light:
|
||||||
|
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(light)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
|
||||||
|
case .dark:
|
||||||
|
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(dark)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.DARK.themeName)
|
||||||
|
case .unspecified:
|
||||||
|
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(light)
|
||||||
|
overrides.append(dark)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
|
||||||
|
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
@unknown default: ()
|
||||||
|
}
|
||||||
|
} else if userInterfaceStyle != .unspecified {
|
||||||
|
let themeName = switch userInterfaceStyle {
|
||||||
|
case .light: DefaultTheme.LIGHT.themeName
|
||||||
|
case .dark: DefaultTheme.DARK.themeName
|
||||||
|
default: DefaultTheme.SYSTEM_THEME_NAME
|
||||||
|
}
|
||||||
|
ThemeManager.applyTheme(themeName)
|
||||||
|
}
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_RED)
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_GREEN)
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_BLUE)
|
||||||
|
defs.removeObject(forKey: DEFAULT_USER_INTERFACE_STYLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"filename": "checkmark.2.svg",
|
||||||
|
"idiom": "universal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>checkmark.2</title>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="double.checkmark">
|
||||||
|
<g id="Notes">
|
||||||
|
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
|
||||||
|
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
|
||||||
|
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||||
|
font-size="13" font-weight="normal">
|
||||||
|
<tspan x="263" y="322">Weight/Scale Variations</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="533.711" y="322">Ultralight</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="843.422" y="322">Thin</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="1138.63" y="322">Light</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="1426.84" y="322">Regular</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="1723.06" y="322">Medium</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2015.77" y="322">Semibold</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2326.48" y="322">Bold</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2618.19" y="322">Heavy</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2917.4" y="322">Black</tspan>
|
||||||
|
</text>
|
||||||
|
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
|
||||||
|
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="263" y="1953">Design Variations</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
|
||||||
|
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
|
||||||
|
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||||
|
font-size="13" font-weight="normal">
|
||||||
|
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
|
||||||
|
</text>
|
||||||
|
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="776" y="1953">Margins</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
|
||||||
|
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
|
||||||
|
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
|
||||||
|
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="776" y="2025">scales and weights.</tspan>
|
||||||
|
</text>
|
||||||
|
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="1289" y="1953">Exporting</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
|
||||||
|
font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
|
||||||
|
font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2952" y="1933">Template v.5.0</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||||
|
font-size="13" font-weight="normal">
|
||||||
|
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||||
|
font-size="13" font-weight="normal">
|
||||||
|
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="263" y="726">Small</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="263" y="1156">Medium</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="263" y="1586">Large</tspan>
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
<g id="Guides" transform="translate(263, 600.785)">
|
||||||
|
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
</g>
|
||||||
|
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
|
||||||
|
<g id="Black-S" transform="translate(2365.995, 0)">
|
||||||
|
<path
|
||||||
|
d="M67.46878,71.191381 C71.17968,71.191381 74.06058,69.873022 76.01368,66.99216 L111.07228,15.2343 C112.43948,13.2324 113.02538,11.1328 113.02538,9.2773 C113.02538,4.0039 108.82618,0 103.35738,0 C99.69528,0 97.30278,1.3183 95.05668,4.834 L67.32228,47.9492 L53.69918,32.6172 C51.79488,30.4687 49.54888,29.4433 46.52148,29.4433 C41.05278,29.4433 37,33.4472 37,38.7695 C37,41.2109 37.63478,43.1152 39.73438,45.459 L59.36328,67.67576 C61.51168,70.117162 64.14848,71.191381 67.46878,71.191381 Z"
|
||||||
|
id="Path"></path>
|
||||||
|
<path
|
||||||
|
d="M9.52148,29.4433 C12.54888,29.4433 14.79488,30.4687 16.69918,32.6172 L30.32228,47.9492 L32.291,44.888 L44.484,58.915 L39.01368,66.99216 C37.1235832,69.780091 34.3645791,71.1046997 30.825305,71.1872572 L30.46878,71.191381 C27.14848,71.191381 24.51168,70.117162 22.36328,67.67576 L2.73438,45.459 C0.63478,43.1152 0,41.2109 0,38.7695 C0,33.4472 4.05278,29.4433 9.52148,29.4433 Z M66.35738,0 C71.82618,0 76.02538,4.0039 76.02538,9.2773 C76.02538,11.1328 75.43948,13.2324 74.07228,15.2343 L61.951,33.129 L49.252,18.52 L58.05668,4.834 C60.2386057,1.41874857 62.5586852,0.0771252245 66.0465687,0.00324899359 Z"
|
||||||
|
id="Combined-Shape"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Regular-S" transform="translate(886.905, 3.7109)">
|
||||||
|
<path
|
||||||
|
d="M55.87888,66.113294 C57.78318,66.113294 59.29688,65.28322 60.37108,63.62306 L95.96678,7.3242 C96.79688,6.0547 97.08988,5.0293 97.08988,4.0039 C97.08988,1.6113 95.52738,0 93.08598,0 C91.32818,0 90.35158,0.586 89.27738,2.2949 L55.68358,56.2012 L38.00778,32.3731 C36.88478,30.8594 35.81058,30.2246 34.19918,30.2246 C31.75778,30.2246 30,31.9336 30,34.375 C30,35.4004 30.43948,36.5234 31.26958,37.5977 L51.24028,63.5254 C52.60738,65.28322 53.97458,66.113294 55.87888,66.113294 Z"
|
||||||
|
id="Path"></path>
|
||||||
|
<path
|
||||||
|
d="M4.19918,30.2246 C5.81058,30.2246 6.88478,30.8594 8.00778,32.3731 L25.68358,56.2012 L30.332,48.741 L35.554,55.426 L30.37108,63.62306 C29.3457073,65.2077582 27.919881,66.0361177 26.1361316,66.1081489 L25.87888,66.113294 C23.97458,66.113294 22.60738,65.28322 21.24028,63.5254 L1.26958,37.5977 C0.43948,36.5234 0,35.4004 0,34.375 C0,31.9336 1.75778,30.2246 4.19918,30.2246 Z M63.08598,0 C65.52738,0 67.08988,1.6113 67.08988,4.0039 C67.08988,5.0293 66.79688,6.0547 65.96678,7.3242 L48.893,34.328 L43.564,27.508 L59.27738,2.2949 C60.3068217,0.657204167 61.2466272,0.0507828125 62.8702602,0.00309092159 Z"
|
||||||
|
id="Combined-Shape"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Ultralight-S" transform="translate(0, 4.4375)">
|
||||||
|
<path
|
||||||
|
d="M36.79298,62.07178 C37.24418,62.07178 37.53178,61.87744 37.78868,61.53417 L76.33598,2.0112 C76.57578,1.6045 76.64168,1.3965 76.64168,1.1885 C76.64168,0.5215 76.12358,0 75.45318,0 C75.01228,0 74.71678,0.1772 74.50538,0.5693 L36.73398,58.97114 L18.24078,37.7768 C17.98048,37.3984 17.67828,37.2177 17.20218,37.2177 C16.48638,37.2177 16,37.7006 16,38.371 C16,38.6699 16.12159,38.9755 16.40678,39.2778 L35.65088,61.52733 C36.01908,61.96826 36.29638,62.07178 36.79298,62.07178 Z"
|
||||||
|
id="Path"></path>
|
||||||
|
<path
|
||||||
|
d="M1.20218,37.2177 C1.67828,37.2177 1.98048,37.3984 2.24078,37.7768 L20.73398,58.97114 L23.078,55.345 L24.636,57.137 L21.78868,61.53417 C21.5603244,61.8392989 21.3077121,62.0267547 20.937503,62.0646445 L20.79298,62.07178 C20.29638,62.07178 20.01908,61.96826 19.65088,61.52733 L0.40678,39.2778 C0.12159,38.9755 0,38.6699 0,38.371 C0,37.7006 0.48638,37.2177 1.20218,37.2177 Z M59.45318,0 C60.12358,0 60.64168,0.5215 60.64168,1.1885 C60.64168,1.3965 60.57578,1.6045 60.33598,2.0112 L32.216,45.432 L30.652,43.634 L58.50538,0.5693 C58.6932911,0.220766667 58.9476516,0.0420308642 59.3115144,0.00661467764 Z"
|
||||||
|
id="Combined-Shape"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"symbols": [
|
||||||
|
{
|
||||||
|
"filename": "checkmark.wide.svg",
|
||||||
|
"idiom": "universal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>checkmark.wide</title>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="double.checkmark">
|
||||||
|
<g id="Notes">
|
||||||
|
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
|
||||||
|
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
|
||||||
|
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||||
|
font-size="13" font-weight="normal">
|
||||||
|
<tspan x="263" y="322">Weight/Scale Variations</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="533.711" y="322">Ultralight</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="843.422" y="322">Thin</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="1138.63" y="322">Light</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="1426.84" y="322">Regular</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="1723.06" y="322">Medium</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2015.77" y="322">Semibold</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2326.48" y="322">Bold</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2618.19" y="322">Heavy</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2917.4" y="322">Black</tspan>
|
||||||
|
</text>
|
||||||
|
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
|
||||||
|
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="263" y="1953">Design Variations</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
|
||||||
|
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
|
||||||
|
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||||
|
font-size="13" font-weight="normal">
|
||||||
|
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
|
||||||
|
</text>
|
||||||
|
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="776" y="1953">Margins</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
|
||||||
|
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
|
||||||
|
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
|
||||||
|
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="776" y="2025">scales and weights.</tspan>
|
||||||
|
</text>
|
||||||
|
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="1289" y="1953">Exporting</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
|
||||||
|
font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
|
||||||
|
font-family="Helvetica" font-size="13" font-weight="normal">
|
||||||
|
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2952" y="1933">Template v.5.0</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||||
|
font-size="13" font-weight="normal">
|
||||||
|
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
|
||||||
|
font-size="13" font-weight="normal">
|
||||||
|
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="263" y="726">Small</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="263" y="1156">Medium</tspan>
|
||||||
|
</text>
|
||||||
|
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
|
||||||
|
font-weight="normal">
|
||||||
|
<tspan x="263" y="1586">Large</tspan>
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
<g id="Guides" transform="translate(263, 600.785)">
|
||||||
|
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
|
||||||
|
id="Shape"></path>
|
||||||
|
</g>
|
||||||
|
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
|
||||||
|
</line>
|
||||||
|
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
|
||||||
|
stroke="#00AEEF" stroke-width="0.5"></line>
|
||||||
|
</g>
|
||||||
|
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
|
||||||
|
<g id="Black-S" transform="translate(2365.995, 0)">
|
||||||
|
<path
|
||||||
|
d="M30.46878,71.191381 C34.17968,71.191381 37.06058,69.873022 39.01368,66.99216 L74.07228,15.2343 C75.43948,13.2324 76.02538,11.1328 76.02538,9.2773 C76.02538,4.0039 71.82618,0 66.35738,0 C62.69528,0 60.30278,1.3183 58.05668,4.834 L30.32228,47.9492 L16.69918,32.6172 C14.79488,30.4687 12.54888,29.4433 9.52148,29.4433 C4.05278,29.4433 0,33.4472 0,38.7695 C0,41.2109 0.63478,43.1152 2.73438,45.459 L22.36328,67.67576 C24.51168,70.117162 27.14848,71.191381 30.46878,71.191381 Z"
|
||||||
|
id="Path"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Regular-S" transform="translate(886.905, 3.7109)">
|
||||||
|
<path
|
||||||
|
d="M25.87888,66.113294 C27.78318,66.113294 29.29688,65.28322 30.37108,63.62306 L65.96678,7.3242 C66.79688,6.0547 67.08988,5.0293 67.08988,4.0039 C67.08988,1.6113 65.52738,0 63.08598,0 C61.32818,0 60.35158,0.586 59.27738,2.2949 L25.68358,56.2012 L8.00778,32.3731 C6.88478,30.8594 5.81058,30.2246 4.19918,30.2246 C1.75778,30.2246 0,31.9336 0,34.375 C0,35.4004 0.43948,36.5234 1.26958,37.5977 L21.24028,63.5254 C22.60738,65.28322 23.97458,66.113294 25.87888,66.113294 Z"
|
||||||
|
id="Path"></path>
|
||||||
|
</g>
|
||||||
|
<g id="Ultralight-S" transform="translate(0, 4.4375)">
|
||||||
|
<path
|
||||||
|
d="M20.79298,62.07178 C21.24418,62.07178 21.53178,61.87744 21.78868,61.53417 L60.33598,2.0112 C60.57578,1.6045 60.64168,1.3965 60.64168,1.1885 C60.64168,0.5215 60.12358,0 59.45318,0 C59.01228,0 58.71678,0.1772 58.50538,0.5693 L20.73398,58.97114 L2.24078,37.7768 C1.98048,37.3984 1.67828,37.2177 1.20218,37.2177 C0.48638,37.2177 0,37.7006 0,38.371 C0,38.6699 0.12159,38.9755 0.40678,39.2778 L19.65088,61.52733 C20.01908,61.96826 20.29638,62.07178 20.79298,62.07178 Z"
|
||||||
|
id="Path"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 233 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -14,6 +14,8 @@ struct ContentView: View {
|
|||||||
@ObservedObject var alertManager = AlertManager.shared
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
@ObservedObject var callController = CallController.shared
|
@ObservedObject var callController = CallController.shared
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||||
|
|
||||||
var contentAccessAuthenticationExtended: Bool
|
var contentAccessAuthenticationExtended: Bool
|
||||||
|
|
||||||
@@ -25,16 +27,15 @@ struct ContentView: View {
|
|||||||
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
||||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = 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
|
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
|
||||||
@State private var showSettings = false
|
|
||||||
@State private var showWhatsNew = false
|
@State private var showWhatsNew = false
|
||||||
@State private var showChooseLAMode = false
|
@State private var showChooseLAMode = false
|
||||||
@State private var showSetPasscode = false
|
@State private var showSetPasscode = false
|
||||||
@State private var waitingForOrPassedAuth = true
|
@State private var waitingForOrPassedAuth = true
|
||||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||||
|
@State private var chatListUserPickerSheet: UserPickerSheet? = nil
|
||||||
|
|
||||||
private let callTopPadding: CGFloat = 50
|
private let callTopPadding: CGFloat = 40
|
||||||
|
|
||||||
private enum ChatListActionSheet: Identifiable {
|
private enum ChatListActionSheet: Identifiable {
|
||||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||||
@@ -51,6 +52,16 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
allViews()
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
} else {
|
||||||
|
// on iOS 15 scroll view background disabled in SceneDelegate
|
||||||
|
allViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func allViews() -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
||||||
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
||||||
@@ -74,7 +85,7 @@ struct ContentView: View {
|
|||||||
callView(call)
|
callView(call)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !showSettings, let la = chatModel.laRequest {
|
if chatListUserPickerSheet == nil, let la = chatModel.laRequest {
|
||||||
LocalAuthView(authRequest: la)
|
LocalAuthView(authRequest: la)
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
|
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
|
||||||
@@ -97,9 +108,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||||
.sheet(isPresented: $showSettings) {
|
|
||||||
SettingsView(showSettings: $showSettings)
|
|
||||||
}
|
|
||||||
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
|
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
|
||||||
Button("System authentication") { initialEnableLA() }
|
Button("System authentication") { initialEnableLA() }
|
||||||
Button("Passcode entry") { showSetPasscode = true }
|
Button("Passcode entry") { showSetPasscode = true }
|
||||||
@@ -138,6 +146,17 @@ struct ContentView: View {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
reactOnDarkThemeChanges(systemInDarkThemeCurrently)
|
||||||
|
}
|
||||||
|
.onChange(of: colorScheme) { scheme in
|
||||||
|
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
|
||||||
|
// so when a user changes his global theme from dark to light or back, the app will adapt to it
|
||||||
|
reactOnDarkThemeChanges(scheme == .dark)
|
||||||
|
}
|
||||||
|
.onChange(of: theme.name) { _ in
|
||||||
|
ThemeManager.adjustWindowStyle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func contentView() -> some View {
|
@ViewBuilder private func contentView() -> some View {
|
||||||
@@ -184,7 +203,7 @@ struct ContentView: View {
|
|||||||
CallDuration(call: call)
|
CallDuration(call: call)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.frame(height: callTopPadding - 10)
|
.frame(height: callTopPadding)
|
||||||
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
chatModel.activeCallViewIsCollapsed = false
|
chatModel.activeCallViewIsCollapsed = false
|
||||||
@@ -224,13 +243,13 @@ struct ContentView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
||||||
.background(
|
.background(
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.background)
|
.fill(theme.colors.background)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mainView() -> some View {
|
private func mainView() -> some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
requestNtfAuthorization()
|
requestNtfAuthorization()
|
||||||
// Local Authentication notice is to be shown on next start after onboarding is complete
|
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||||
@@ -277,9 +296,18 @@ struct ContentView: View {
|
|||||||
if let contactId = contacts?.first?.personHandle?.value,
|
if let contactId = contacts?.first?.personHandle?.value,
|
||||||
let chat = chatModel.getChat(contactId),
|
let chat = chatModel.getChat(contactId),
|
||||||
case let .direct(contact) = chat.chatInfo {
|
case let .direct(contact) = chat.chatInfo {
|
||||||
logger.debug("callToRecentContact: schedule call")
|
let activeCall = chatModel.activeCall
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
// This line works when a user clicks on a video button in CallKit UI while in call.
|
||||||
CallController.shared.startCall(contact, mediaType)
|
// The app tries to make another call to the same contact and overwite activeCall instance making its state broken
|
||||||
|
if let activeCall, contactId == activeCall.contact.id, mediaType == .video, !activeCall.hasVideo {
|
||||||
|
Task {
|
||||||
|
await chatModel.callCommand.processCommand(.media(source: .camera, enable: true))
|
||||||
|
}
|
||||||
|
} else if activeCall == nil {
|
||||||
|
logger.debug("callToRecentContact: schedule call")
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
|
CallController.shared.startCall(contact, mediaType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,103 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
|
|||||||
items.append(item)
|
items.append(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ItemsModel: ObservableObject {
|
||||||
|
static let shared = ItemsModel()
|
||||||
|
private let publisher = ObservableObjectPublisher()
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
var reversedChatItems: [ChatItem] = [] {
|
||||||
|
willSet { publisher.send() }
|
||||||
|
}
|
||||||
|
var itemAdded = false {
|
||||||
|
willSet { publisher.send() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publishes directly to `objectWillChange` publisher,
|
||||||
|
// this will cause reversedChatItems to be rendered without throttling
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var showLoadingProgress = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
publisher
|
||||||
|
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
|
||||||
|
.sink { self.objectWillChange.send() }
|
||||||
|
.store(in: &bag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
|
||||||
|
let navigationTimeout = Task {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 250_000000)
|
||||||
|
await MainActor.run {
|
||||||
|
willNavigate()
|
||||||
|
ChatModel.shared.chatId = chatId
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
let progressTimeout = Task {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 1500_000000)
|
||||||
|
await MainActor.run { showLoadingProgress = true }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
if let chat = ChatModel.shared.getChat(chatId) {
|
||||||
|
await MainActor.run { self.isLoading = true }
|
||||||
|
// try? await Task.sleep(nanoseconds: 5000_000000)
|
||||||
|
await loadChat(chat: chat)
|
||||||
|
navigationTimeout.cancel()
|
||||||
|
progressTimeout.cancel()
|
||||||
|
await MainActor.run {
|
||||||
|
self.isLoading = false
|
||||||
|
self.showLoadingProgress = false
|
||||||
|
willNavigate()
|
||||||
|
ChatModel.shared.chatId = chatId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NetworkModel: ObservableObject {
|
||||||
|
// map of connections network statuses, key is agent connection id
|
||||||
|
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
||||||
|
|
||||||
|
static let shared = NetworkModel()
|
||||||
|
|
||||||
|
private init() { }
|
||||||
|
|
||||||
|
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
|
||||||
|
if let conn = contact.activeConn {
|
||||||
|
networkStatuses[conn.agentConnId] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
|
||||||
|
if let conn = contact.activeConn {
|
||||||
|
networkStatuses[conn.agentConnId] ?? .unknown
|
||||||
|
} else {
|
||||||
|
.unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ChatItemWithMenu can depend on previous or next item for it's appearance
|
||||||
|
/// This dummy model is used to force an update of all chat items,
|
||||||
|
/// when they might have changed appearance.
|
||||||
|
class ChatItemDummyModel: ObservableObject {
|
||||||
|
static let shared = ChatItemDummyModel()
|
||||||
|
func sendUpdate() { objectWillChange.send() }
|
||||||
|
}
|
||||||
|
|
||||||
final class ChatModel: ObservableObject {
|
final class ChatModel: ObservableObject {
|
||||||
@Published var onboardingStage: OnboardingStage?
|
@Published var onboardingStage: OnboardingStage?
|
||||||
@Published var setDeliveryReceipts = false
|
@Published var setDeliveryReceipts = false
|
||||||
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
||||||
@Published var currentUser: User?
|
@Published var currentUser: User? {
|
||||||
|
didSet {
|
||||||
|
ThemeManager.applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
@Published var users: [UserInfo] = []
|
@Published var users: [UserInfo] = []
|
||||||
@Published var chatInitialized = false
|
@Published var chatInitialized = false
|
||||||
@Published var chatRunning: Bool?
|
@Published var chatRunning: Bool?
|
||||||
@@ -59,16 +151,15 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var contentViewAccessAuthenticated: Bool = false
|
@Published var contentViewAccessAuthenticated: Bool = false
|
||||||
@Published var laRequest: LocalAuthRequest?
|
@Published var laRequest: LocalAuthRequest?
|
||||||
// list of chat "previews"
|
// list of chat "previews"
|
||||||
@Published var chats: [Chat] = []
|
@Published private(set) var chats: [Chat] = []
|
||||||
@Published var deletedChats: Set<String> = []
|
@Published var deletedChats: Set<String> = []
|
||||||
// map of connections network statuses, key is agent connection id
|
|
||||||
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
|
||||||
// current chat
|
// current chat
|
||||||
@Published var chatId: String?
|
@Published var chatId: String?
|
||||||
@Published var reversedChatItems: [ChatItem] = []
|
|
||||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||||
@Published var chatToTop: String?
|
@Published var chatToTop: String?
|
||||||
@Published var groupMembers: [GMember] = []
|
@Published var groupMembers: [GMember] = []
|
||||||
|
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
|
||||||
|
@Published var membersLoaded = false
|
||||||
// items in the terminal view
|
// items in the terminal view
|
||||||
@Published var showingTerminal = false
|
@Published var showingTerminal = false
|
||||||
@Published var terminalItems: [TerminalItem] = []
|
@Published var terminalItems: [TerminalItem] = []
|
||||||
@@ -100,9 +191,6 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||||
@Published var draft: ComposeState?
|
@Published var draft: ComposeState?
|
||||||
@Published var draftChatId: String?
|
@Published var draftChatId: String?
|
||||||
// tracks keyboard height via subscription in AppDelegate
|
|
||||||
@Published var keyboardHeight: CGFloat = 0
|
|
||||||
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
|
|
||||||
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||||
|
|
||||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||||
@@ -111,6 +199,8 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
static let shared = ChatModel()
|
static let shared = ChatModel()
|
||||||
|
|
||||||
|
let im = ItemsModel.shared
|
||||||
|
|
||||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||||
|
|
||||||
let ntfEnableLocal = true
|
let ntfEnableLocal = true
|
||||||
@@ -176,18 +266,47 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func populateGroupMembersIndexes() {
|
||||||
|
groupMembersIndexes.removeAll()
|
||||||
|
for (i, member) in groupMembers.enumerated() {
|
||||||
|
groupMembersIndexes[member.groupMemberId] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
||||||
groupMembers.first { $0.groupMemberId == groupMemberId }
|
if let i = groupMembersIndexes[groupMemberId] {
|
||||||
|
return groupMembers[i]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
|
||||||
|
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||||
|
await MainActor.run {
|
||||||
|
if chatId == groupInfo.id {
|
||||||
|
self.groupMembers = groupMembers.map { GMember.init($0) }
|
||||||
|
self.populateGroupMembersIndexes()
|
||||||
|
self.membersLoaded = true
|
||||||
|
updateView()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getChatIndex(_ id: String) -> Int? {
|
private func getChatIndex(_ id: String) -> Int? {
|
||||||
chats.firstIndex(where: { $0.id == id })
|
chats.firstIndex(where: { $0.id == id })
|
||||||
}
|
}
|
||||||
|
|
||||||
func addChat(_ chat: Chat, at position: Int = 0) {
|
func addChat(_ chat: Chat) {
|
||||||
withAnimation {
|
if chatId == nil {
|
||||||
chats.insert(chat, at: position)
|
withAnimation { addChat_(chat, at: 0) }
|
||||||
|
} else {
|
||||||
|
addChat_(chat, at: 0)
|
||||||
}
|
}
|
||||||
|
popChatCollector.throttlePopChat(chat.chatInfo.id, currentPosition: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addChat_(_ chat: Chat, at position: Int = 0) {
|
||||||
|
chats.insert(chat, at: position)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateChatInfo(_ cInfo: ChatInfo) {
|
func updateChatInfo(_ cInfo: ChatInfo) {
|
||||||
@@ -245,26 +364,10 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateChats(with newChats: [ChatData]) {
|
func updateChats(_ newChats: [ChatData]) {
|
||||||
for i in 0..<newChats.count {
|
chats = newChats.map { Chat($0) }
|
||||||
let c = newChats[i]
|
|
||||||
if let j = getChatIndex(c.id) {
|
|
||||||
let chat = chats[j]
|
|
||||||
chat.chatInfo = c.chatInfo
|
|
||||||
chat.chatItems = c.chatItems
|
|
||||||
chat.chatStats = c.chatStats
|
|
||||||
if i != j {
|
|
||||||
if chatId != c.chatInfo.id {
|
|
||||||
popChat_(j, to: i)
|
|
||||||
} else if i == 0 {
|
|
||||||
chatToTop = c.chatInfo.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addChat(Chat(c), at: i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
|
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
|
||||||
|
popChatCollector.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// func addGroup(_ group: SimpleXChat.Group) {
|
// func addGroup(_ group: SimpleXChat.Group) {
|
||||||
@@ -272,6 +375,12 @@ final class ChatModel: ObservableObject {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
|
// mark chat non deleted
|
||||||
|
if case let .direct(contact) = cInfo, contact.chatDeleted {
|
||||||
|
var updatedContact = contact
|
||||||
|
updatedContact.chatDeleted = false
|
||||||
|
updateContact(updatedContact)
|
||||||
|
}
|
||||||
// update previews
|
// update previews
|
||||||
if let i = getChatIndex(cInfo.id) {
|
if let i = getChatIndex(cInfo.id) {
|
||||||
chats[i].chatItems = switch cInfo {
|
chats[i].chatItems = switch cInfo {
|
||||||
@@ -289,18 +398,9 @@ final class ChatModel: ObservableObject {
|
|||||||
[cItem]
|
[cItem]
|
||||||
}
|
}
|
||||||
if case .rcvNew = cItem.meta.itemStatus {
|
if case .rcvNew = cItem.meta.itemStatus {
|
||||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
|
||||||
increaseUnreadCounter(user: currentUser!)
|
|
||||||
}
|
|
||||||
if i > 0 {
|
|
||||||
if chatId == nil {
|
|
||||||
withAnimation { popChat_(i) }
|
|
||||||
} else if chatId == cInfo.id {
|
|
||||||
chatToTop = cInfo.id
|
|
||||||
} else {
|
|
||||||
popChat_(i)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
|
||||||
} else {
|
} else {
|
||||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||||
}
|
}
|
||||||
@@ -315,7 +415,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var res: Bool
|
var res: Bool
|
||||||
if let chat = getChat(cInfo.id) {
|
if let chat = getChat(cInfo.id) {
|
||||||
if let pItem = chat.chatItems.last {
|
if let pItem = chat.chatItems.last {
|
||||||
if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
|
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
|
||||||
chat.chatItems = [cItem]
|
chat.chatItems = [cItem]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -326,24 +426,26 @@ final class ChatModel: ObservableObject {
|
|||||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||||
res = true
|
res = true
|
||||||
}
|
}
|
||||||
|
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
|
||||||
|
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||||
|
}
|
||||||
// update current chat
|
// update current chat
|
||||||
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
|
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
||||||
if let i = getChatItemIndex(cItem) {
|
if let i = getChatItemIndex(cItem) {
|
||||||
withAnimation {
|
_updateChatItem(at: i, with: cItem)
|
||||||
_updateChatItem(at: i, with: cItem)
|
ChatItemDummyModel.shared.sendUpdate()
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
withAnimation(itemAnimation()) {
|
var ci = cItem
|
||||||
var ci = cItem
|
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
ci.meta.itemStatus = status
|
||||||
ci.meta.itemStatus = status
|
|
||||||
}
|
|
||||||
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
|
||||||
}
|
}
|
||||||
|
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||||
|
im.itemAdded = true
|
||||||
|
ChatItemDummyModel.shared.sendUpdate()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +459,7 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
||||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||||
withAnimation {
|
withConditionalAnimation {
|
||||||
_updateChatItem(at: i, with: cItem)
|
_updateChatItem(at: i, with: cItem)
|
||||||
}
|
}
|
||||||
} else if let status = status {
|
} else if let status = status {
|
||||||
@@ -366,17 +468,17 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
||||||
reversedChatItems[i] = cItem
|
im.reversedChatItems[i] = cItem
|
||||||
reversedChatItems[i].viewTimestamp = .now
|
im.reversedChatItems[i].viewTimestamp = .now
|
||||||
}
|
}
|
||||||
|
|
||||||
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
||||||
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
if cItem.isRcvNew {
|
if cItem.isRcvNew {
|
||||||
decreaseUnreadCounter(cInfo)
|
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||||
}
|
}
|
||||||
// update previews
|
// update previews
|
||||||
if let chat = getChat(cInfo.id) {
|
if let chat = getChat(cInfo.id) {
|
||||||
@@ -388,23 +490,24 @@ final class ChatModel: ObservableObject {
|
|||||||
if chatId == cInfo.id {
|
if chatId == cInfo.id {
|
||||||
if let i = getChatItemIndex(cItem) {
|
if let i = getChatItemIndex(cItem) {
|
||||||
_ = withAnimation {
|
_ = withAnimation {
|
||||||
self.reversedChatItems.remove(at: i)
|
im.reversedChatItems.remove(at: i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
|
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
|
||||||
guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
|
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
|
||||||
if previous {
|
if previous {
|
||||||
while i < reversedChatItems.count - 1 {
|
while i < im.reversedChatItems.count - 1 {
|
||||||
i += 1
|
i += 1
|
||||||
if let res = map(reversedChatItems[i]) { return res }
|
if let res = map(im.reversedChatItems[i]) { return res }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while i > 0 {
|
while i > 0 {
|
||||||
i -= 1
|
i -= 1
|
||||||
if let res = map(reversedChatItems[i]) { return res }
|
if let res = map(im.reversedChatItems[i]) { return res }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -422,10 +525,21 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
|
||||||
|
guard var current = currentUser, current.uiThemes != uiThemes else { return }
|
||||||
|
current.uiThemes = uiThemes
|
||||||
|
let i = users.firstIndex(where: { $0.user.userId == current.userId })
|
||||||
|
if let i {
|
||||||
|
users[i].user = current
|
||||||
|
}
|
||||||
|
currentUser = current
|
||||||
|
}
|
||||||
|
|
||||||
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
||||||
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
||||||
withAnimation {
|
withAnimation {
|
||||||
reversedChatItems.insert(cItem, at: 0)
|
im.reversedChatItems.insert(cItem, at: 0)
|
||||||
|
im.itemAdded = true
|
||||||
}
|
}
|
||||||
return cItem
|
return cItem
|
||||||
}
|
}
|
||||||
@@ -433,21 +547,22 @@ final class ChatModel: ObservableObject {
|
|||||||
func removeLiveDummy(animated: Bool = true) {
|
func removeLiveDummy(animated: Bool = true) {
|
||||||
if hasLiveDummy {
|
if hasLiveDummy {
|
||||||
if animated {
|
if animated {
|
||||||
withAnimation { _ = reversedChatItems.removeFirst() }
|
withAnimation { _ = im.reversedChatItems.removeFirst() }
|
||||||
} else {
|
} else {
|
||||||
_ = reversedChatItems.removeFirst()
|
_ = im.reversedChatItems.removeFirst()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hasLiveDummy: Bool {
|
private var hasLiveDummy: Bool {
|
||||||
reversedChatItems.first?.isLiveDummy == true
|
im.reversedChatItems.first?.isLiveDummy == true
|
||||||
}
|
}
|
||||||
|
|
||||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||||
// update preview
|
// update preview
|
||||||
_updateChat(cInfo.id) { chat in
|
_updateChat(cInfo.id) { chat in
|
||||||
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
|
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
|
||||||
|
self.updateFloatingButtons(unreadCount: 0)
|
||||||
chat.chatStats = ChatStats()
|
chat.chatStats = ChatStats()
|
||||||
}
|
}
|
||||||
// update current chat
|
// update current chat
|
||||||
@@ -458,12 +573,18 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||||
var j = i
|
var j = i
|
||||||
while j < reversedChatItems.count {
|
while j < im.reversedChatItems.count {
|
||||||
markChatItemRead_(j)
|
markChatItemRead_(j)
|
||||||
j += 1
|
j += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateFloatingButtons(unreadCount: Int) {
|
||||||
|
let fbm = ChatView.FloatingButtonModel.shared
|
||||||
|
fbm.totalUnread = unreadCount
|
||||||
|
fbm.objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
|
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
|
||||||
if let cItem = aboveItem {
|
if let cItem = aboveItem {
|
||||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||||
@@ -472,7 +593,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var unreadBelow = 0
|
var unreadBelow = 0
|
||||||
var j = i - 1
|
var j = i - 1
|
||||||
while j >= 0 {
|
while j >= 0 {
|
||||||
if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
|
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
|
||||||
unreadBelow += 1
|
unreadBelow += 1
|
||||||
}
|
}
|
||||||
j -= 1
|
j -= 1
|
||||||
@@ -482,6 +603,7 @@ final class ChatModel: ObservableObject {
|
|||||||
if markedCount > 0 {
|
if markedCount > 0 {
|
||||||
chat.chatStats.unreadCount -= markedCount
|
chat.chatStats.unreadCount -= markedCount
|
||||||
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
|
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
|
||||||
|
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,51 +629,143 @@ final class ChatModel: ObservableObject {
|
|||||||
// clear current chat
|
// clear current chat
|
||||||
if chatId == cInfo.id {
|
if chatId == cInfo.id {
|
||||||
chatItemStatuses = [:]
|
chatItemStatuses = [:]
|
||||||
reversedChatItems = []
|
im.reversedChatItems = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) {
|
||||||
// update preview
|
if self.chatId == cInfo.id {
|
||||||
decreaseUnreadCounter(cInfo)
|
for itemId in itemIds {
|
||||||
// update current chat
|
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
|
||||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
markChatItemRead_(i)
|
||||||
markChatItemRead_(i)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let unreadCollector = UnreadCollector()
|
||||||
|
|
||||||
|
class UnreadCollector {
|
||||||
|
private let subject = PassthroughSubject<Void, Never>()
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
private var unreadCounts: [ChatId: Int] = [:]
|
||||||
|
|
||||||
|
init() {
|
||||||
|
subject
|
||||||
|
.debounce(for: 1, scheduler: DispatchQueue.main)
|
||||||
|
.sink {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
for (chatId, count) in self.unreadCounts {
|
||||||
|
if let i = m.getChatIndex(chatId) {
|
||||||
|
m.changeUnreadCounter(i, by: count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.unreadCounts = [:]
|
||||||
|
}
|
||||||
|
.store(in: &bag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
|
||||||
|
if chatId == ChatModel.shared.chatId {
|
||||||
|
ChatView.FloatingButtonModel.shared.totalUnread += count
|
||||||
|
}
|
||||||
|
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
|
||||||
|
subject.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let popChatCollector = PopChatCollector()
|
||||||
|
|
||||||
|
class PopChatCollector {
|
||||||
|
private let subject = PassthroughSubject<Void, Never>()
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
private var chatsToPop: [ChatId: Date] = [:]
|
||||||
|
private let popTsComparator = KeyPathComparator<Chat>(\.popTs, order: .reverse)
|
||||||
|
|
||||||
|
init() {
|
||||||
|
subject
|
||||||
|
.throttle(for: 2, scheduler: DispatchQueue.main, latest: true)
|
||||||
|
.sink { self.popCollectedChats() }
|
||||||
|
.store(in: &bag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func throttlePopChat(_ chatId: ChatId, currentPosition: Int) {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
if currentPosition > 0 && m.chatId == chatId {
|
||||||
|
m.chatToTop = chatId
|
||||||
|
}
|
||||||
|
if currentPosition > 0 || !chatsToPop.isEmpty {
|
||||||
|
chatsToPop[chatId] = Date.now
|
||||||
|
subject.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
chatsToPop = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func popCollectedChats() {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
var ixs: IndexSet = []
|
||||||
|
var chs: [Chat] = []
|
||||||
|
// collect chats that received updates
|
||||||
|
for (chatId, popTs) in self.chatsToPop {
|
||||||
|
// Currently opened chat is excluded, removing it from the list would navigate out of it
|
||||||
|
// It will be popped to top later when user exits from the list.
|
||||||
|
if m.chatId != chatId, let i = m.getChatIndex(chatId) {
|
||||||
|
ixs.insert(i)
|
||||||
|
let ch = m.chats[i]
|
||||||
|
ch.popTs = popTs
|
||||||
|
chs.append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removeInsert = {
|
||||||
|
m.chats.remove(atOffsets: ixs)
|
||||||
|
// sort chats by pop timestamp in descending order
|
||||||
|
m.chats.insert(contentsOf: chs.sorted(using: self.popTsComparator), at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.chatId == nil {
|
||||||
|
withAnimation { removeInsert() }
|
||||||
|
} else {
|
||||||
|
removeInsert()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.chatsToPop = [:]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func markChatItemRead_(_ i: Int) {
|
private func markChatItemRead_(_ i: Int) {
|
||||||
let meta = reversedChatItems[i].meta
|
let meta = im.reversedChatItems[i].meta
|
||||||
if case .rcvNew = meta.itemStatus {
|
if case .rcvNew = meta.itemStatus {
|
||||||
reversedChatItems[i].meta.itemStatus = .rcvRead
|
im.reversedChatItems[i].meta.itemStatus = .rcvRead
|
||||||
reversedChatItems[i].viewTimestamp = .now
|
im.reversedChatItems[i].viewTimestamp = .now
|
||||||
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
|
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
|
||||||
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
|
func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
|
||||||
if let i = getChatIndex(cInfo.id) {
|
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
|
||||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
changeUnreadCounter(user: currentUser!, by: count)
|
||||||
decreaseUnreadCounter(user: currentUser!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func increaseUnreadCounter(user: any UserLike) {
|
func increaseUnreadCounter(user: any UserLike) {
|
||||||
changeUnreadCounter(user: user, by: 1)
|
changeUnreadCounter(user: user, by: 1)
|
||||||
NtfManager.shared.incNtfBadgeCount()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
||||||
changeUnreadCounter(user: user, by: -by)
|
changeUnreadCounter(user: user, by: -by)
|
||||||
NtfManager.shared.decNtfBadgeCount(by: by)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func changeUnreadCounter(user: any UserLike, by: Int) {
|
private func changeUnreadCounter(user: any UserLike, by: Int) {
|
||||||
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
|
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
|
||||||
users[i].unreadCount += by
|
users[i].unreadCount += by
|
||||||
}
|
}
|
||||||
|
NtfManager.shared.changeNtfBadgeCount(by: by)
|
||||||
}
|
}
|
||||||
|
|
||||||
func totalUnreadCountForAllUsers() -> Int {
|
func totalUnreadCountForAllUsers() -> Int {
|
||||||
@@ -565,8 +779,8 @@ final class ChatModel: ObservableObject {
|
|||||||
var ns: [String] = []
|
var ns: [String] = []
|
||||||
if let ciCategory = chatItem.mergeCategory,
|
if let ciCategory = chatItem.mergeCategory,
|
||||||
var i = getChatItemIndex(chatItem) {
|
var i = getChatItemIndex(chatItem) {
|
||||||
while i < reversedChatItems.count {
|
while i < im.reversedChatItems.count {
|
||||||
let ci = reversedChatItems[i]
|
let ci = im.reversedChatItems[i]
|
||||||
if ci.mergeCategory != ciCategory { break }
|
if ci.mergeCategory != ciCategory { break }
|
||||||
if let m = ci.memberConnected {
|
if let m = ci.memberConnected {
|
||||||
ns.append(m.displayName)
|
ns.append(m.displayName)
|
||||||
@@ -581,7 +795,7 @@ final class ChatModel: ObservableObject {
|
|||||||
// returns the index of the passed item and the next item (it has smaller index)
|
// returns the index of the passed item and the next item (it has smaller index)
|
||||||
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
|
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
|
||||||
if let i = getChatItemIndex(ci) {
|
if let i = getChatItemIndex(ci) {
|
||||||
(i, i > 0 ? reversedChatItems[i - 1] : nil)
|
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
|
||||||
} else {
|
} else {
|
||||||
(nil, nil)
|
(nil, nil)
|
||||||
}
|
}
|
||||||
@@ -591,10 +805,10 @@ final class ChatModel: ObservableObject {
|
|||||||
// and the previous visible item with another merge category
|
// and the previous visible item with another merge category
|
||||||
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
|
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
|
||||||
guard var i = ciIndex else { return (nil, nil) }
|
guard var i = ciIndex else { return (nil, nil) }
|
||||||
let fst = reversedChatItems.count - 1
|
let fst = im.reversedChatItems.count - 1
|
||||||
while i < fst {
|
while i < fst {
|
||||||
i = i + 1
|
i = i + 1
|
||||||
let ci = reversedChatItems[i]
|
let ci = im.reversedChatItems[i]
|
||||||
if ciCategory == nil || ciCategory != ci.mergeCategory {
|
if ciCategory == nil || ciCategory != ci.mergeCategory {
|
||||||
return (i - 1, ci)
|
return (i - 1, ci)
|
||||||
}
|
}
|
||||||
@@ -607,7 +821,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var prevMember: GroupMember? = nil
|
var prevMember: GroupMember? = nil
|
||||||
var memberIds: Set<Int64> = []
|
var memberIds: Set<Int64> = []
|
||||||
for i in range {
|
for i in range {
|
||||||
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
|
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
|
||||||
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
||||||
memberIds.insert(m.groupMemberId)
|
memberIds.insert(m.groupMemberId)
|
||||||
}
|
}
|
||||||
@@ -617,6 +831,7 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
func popChat(_ id: String) {
|
func popChat(_ id: String) {
|
||||||
if let i = getChatIndex(id) {
|
if let i = getChatIndex(id) {
|
||||||
|
// no animation here, for it not to look like it just moved when leaving the chat
|
||||||
popChat_(i)
|
popChat_(i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,14 +866,17 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
// update current chat
|
// update current chat
|
||||||
if chatId == groupInfo.id {
|
if chatId == groupInfo.id {
|
||||||
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
if let i = groupMembersIndexes[member.groupMemberId] {
|
||||||
withAnimation(.default) {
|
withAnimation(.default) {
|
||||||
self.groupMembers[i].wrapped = member
|
self.groupMembers[i].wrapped = member
|
||||||
self.groupMembers[i].created = Date.now
|
self.groupMembers[i].created = Date.now
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
withAnimation { groupMembers.append(GMember(member)) }
|
withAnimation {
|
||||||
|
groupMembers.append(GMember(member))
|
||||||
|
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -674,43 +892,6 @@ final class ChatModel: ObservableObject {
|
|||||||
_ = upsertGroupMember(groupInfo, updatedMember)
|
_ = upsertGroupMember(groupInfo, updatedMember)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
|
|
||||||
var i = 0
|
|
||||||
var totalBelow = 0
|
|
||||||
var unreadBelow = 0
|
|
||||||
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
|
|
||||||
totalBelow += 1
|
|
||||||
if reversedChatItems[i].isRcvNew {
|
|
||||||
unreadBelow += 1
|
|
||||||
}
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
|
|
||||||
}
|
|
||||||
|
|
||||||
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
|
||||||
let maxIx = reversedChatItems.count - 1
|
|
||||||
var i = 0
|
|
||||||
let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
|
|
||||||
while i < maxIx && !inView(i) { i += 1 }
|
|
||||||
while i < maxIx && inView(i) { i += 1 }
|
|
||||||
return reversedChatItems[min(i - 1, maxIx)]
|
|
||||||
}
|
|
||||||
|
|
||||||
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
|
|
||||||
if let conn = contact.activeConn {
|
|
||||||
networkStatuses[conn.agentConnId] = status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
|
|
||||||
if let conn = contact.activeConn {
|
|
||||||
networkStatuses[conn.agentConnId] ?? .unknown
|
|
||||||
} else {
|
|
||||||
.unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ShowingInvitation {
|
struct ShowingInvitation {
|
||||||
@@ -723,16 +904,12 @@ struct NTFContactRequest {
|
|||||||
var chatId: String
|
var chatId: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UnreadChatItemCounts {
|
final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||||
var totalBelow: Int
|
|
||||||
var unreadBelow: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
final class Chat: ObservableObject, Identifiable {
|
|
||||||
@Published var chatInfo: ChatInfo
|
@Published var chatInfo: ChatInfo
|
||||||
@Published var chatItems: [ChatItem]
|
@Published var chatItems: [ChatItem]
|
||||||
@Published var chatStats: ChatStats
|
@Published var chatStats: ChatStats
|
||||||
var created = Date.now
|
var created = Date.now
|
||||||
|
fileprivate var popTs: Date?
|
||||||
|
|
||||||
init(_ cData: ChatData) {
|
init(_ cData: ChatData) {
|
||||||
self.chatInfo = cData.chatInfo
|
self.chatInfo = cData.chatInfo
|
||||||
@@ -779,24 +956,6 @@ final class Chat: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
||||||
|
|
||||||
func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
|
|
||||||
if case let .group(groupInfo) = self.chatInfo {
|
|
||||||
let p = groupInfo.fullGroupPreferences
|
|
||||||
return switch feature {
|
|
||||||
case .timedMessages: p.timedMessages.on
|
|
||||||
case .directMessages: p.directMessages.on(for: groupInfo.membership)
|
|
||||||
case .fullDelete: p.fullDelete.on
|
|
||||||
case .reactions: p.reactions.on
|
|
||||||
case .voice: p.voice.on(for: groupInfo.membership)
|
|
||||||
case .files: p.files.on(for: groupInfo.membership)
|
|
||||||
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
|
|
||||||
case .history: p.history.on
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
|
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
chatModel.chatId = content.targetContentIdentifier
|
if let chatId = content.targetContentIdentifier {
|
||||||
|
ItemsModel.shared.loadOpenChat(chatId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
handler()
|
handler()
|
||||||
}
|
}
|
||||||
@@ -238,12 +240,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
ntfBadgeCountGroupDefault.set(count)
|
ntfBadgeCountGroupDefault.set(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func decNtfBadgeCount(by count: Int = 1) {
|
func changeNtfBadgeCount(by count: Int = 1) {
|
||||||
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
|
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count))
|
||||||
}
|
|
||||||
|
|
||||||
func incNtfBadgeCount(by count: Int = 1) {
|
|
||||||
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addNotification(_ content: UNMutableNotificationContent) {
|
private func addNotification(_ content: UNMutableNotificationContent) {
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ private func _suspendChat(timeout: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let seSubscriber = seMessageSubscriber {
|
||||||
|
switch $0 {
|
||||||
|
case let .state(state):
|
||||||
|
switch state {
|
||||||
|
case .inactive:
|
||||||
|
if AppChatState.shared.value.inactive { activateChat() }
|
||||||
|
case .sendingMessage:
|
||||||
|
if AppChatState.shared.value.canSuspend { suspendChat() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func suspendChat() {
|
func suspendChat() {
|
||||||
suspendLockQueue.sync {
|
suspendLockQueue.sync {
|
||||||
_suspendChat(timeout: appSuspendTimeout)
|
_suspendChat(timeout: appSuspendTimeout)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ struct SimpleXApp: App {
|
|||||||
// so that it's computed by the time view renders, and not on event after rendering
|
// so that it's computed by the time view renders, and not on event after rendering
|
||||||
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
||||||
.environmentObject(chatModel)
|
.environmentObject(chatModel)
|
||||||
|
.environmentObject(AppTheme.shared)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
logger.debug("ContentView.onOpenURL: \(url)")
|
logger.debug("ContentView.onOpenURL: \(url)")
|
||||||
chatModel.appOpenUrl = url
|
chatModel.appOpenUrl = url
|
||||||
@@ -57,6 +58,7 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { phase in
|
||||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||||
|
AppSheetState.shared.scenePhaseActive = phase == .active
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case .background:
|
case .background:
|
||||||
// --- authentication
|
// --- authentication
|
||||||
@@ -81,9 +83,11 @@ struct SimpleXApp: App {
|
|||||||
if appState != .stopped {
|
if appState != .stopped {
|
||||||
startChatAndActivate {
|
startChatAndActivate {
|
||||||
if appState.inactive && chatModel.chatRunning == true {
|
if appState.inactive && chatModel.chatRunning == true {
|
||||||
updateChats()
|
Task {
|
||||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
await updateChats()
|
||||||
updateCallInvitations()
|
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||||
|
await updateCallInvitations()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,16 +132,16 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateChats() {
|
private func updateChats() async {
|
||||||
do {
|
do {
|
||||||
let chats = try apiGetChats()
|
let chats = try await apiGetChatsAsync()
|
||||||
chatModel.updateChats(with: chats)
|
await MainActor.run { chatModel.updateChats(chats) }
|
||||||
if let id = chatModel.chatId,
|
if let id = chatModel.chatId,
|
||||||
let chat = chatModel.getChat(id) {
|
let chat = chatModel.getChat(id) {
|
||||||
loadChat(chat: chat)
|
Task { await loadChat(chat: chat, clearItems: false) }
|
||||||
}
|
}
|
||||||
if let ncr = chatModel.ntfContactRequest {
|
if let ncr = chatModel.ntfContactRequest {
|
||||||
chatModel.ntfContactRequest = nil
|
await MainActor.run { chatModel.ntfContactRequest = nil }
|
||||||
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
|
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
|
||||||
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
|
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
|
||||||
}
|
}
|
||||||
@@ -147,9 +151,9 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCallInvitations() {
|
private func updateCallInvitations() async {
|
||||||
do {
|
do {
|
||||||
try refreshCallInvitations()
|
try await refreshCallInvitations()
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
|
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
//
|
||||||
|
// Theme.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 14.06.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
|
||||||
|
var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } }
|
||||||
|
var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 }
|
||||||
|
|
||||||
|
func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight }
|
||||||
|
|
||||||
|
class AppTheme: ObservableObject, Equatable {
|
||||||
|
static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper)
|
||||||
|
|
||||||
|
var name: String
|
||||||
|
var base: DefaultTheme
|
||||||
|
@ObservedObject var colors: Colors
|
||||||
|
@ObservedObject var appColors: AppColors
|
||||||
|
@ObservedObject var wallpaper: AppWallpaper
|
||||||
|
|
||||||
|
init(name: String, base: DefaultTheme, colors: Colors, appColors: AppColors, wallpaper: AppWallpaper) {
|
||||||
|
self.name = name
|
||||||
|
self.base = base
|
||||||
|
self.colors = colors
|
||||||
|
self.appColors = appColors
|
||||||
|
self.wallpaper = wallpaper
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: AppTheme, rhs: AppTheme) -> Bool {
|
||||||
|
lhs.name == rhs.name &&
|
||||||
|
lhs.colors == rhs.colors &&
|
||||||
|
lhs.appColors == rhs.appColors &&
|
||||||
|
lhs.wallpaper == rhs.wallpaper
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFromCurrentColors() {
|
||||||
|
objectWillChange.send()
|
||||||
|
name = CurrentColors.name
|
||||||
|
base = CurrentColors.base
|
||||||
|
colors.updateColorsFrom(CurrentColors.colors)
|
||||||
|
appColors.updateColorsFrom(CurrentColors.appColors)
|
||||||
|
wallpaper.updateWallpaperFrom(CurrentColors.wallpaper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ThemedBackground: ViewModifier {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
var grouped: Bool = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background(
|
||||||
|
theme.base == DefaultTheme.SIMPLEX
|
||||||
|
? LinearGradient(
|
||||||
|
colors: [
|
||||||
|
grouped
|
||||||
|
? theme.colors.background.lighter(0.4).asGroupedBackground(theme.base.mode)
|
||||||
|
: theme.colors.background.lighter(0.4),
|
||||||
|
grouped
|
||||||
|
? theme.colors.background.darker(0.4).asGroupedBackground(theme.base.mode)
|
||||||
|
: theme.colors.background.darker(0.4)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
: LinearGradient(
|
||||||
|
colors: [],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
theme.base == DefaultTheme.SIMPLEX
|
||||||
|
? Color.clear
|
||||||
|
: grouped
|
||||||
|
? theme.colors.background.asGroupedBackground(theme.base.mode)
|
||||||
|
: theme.colors.background
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemInDarkThemeCurrently: Bool {
|
||||||
|
return UITraitCollection.current.userInterfaceStyle == .dark
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactOnDarkThemeChanges(_ inDarkNow: Bool) {
|
||||||
|
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow {
|
||||||
|
// Change active colors from light to dark and back based on system theme
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ThemeWallpaper {
|
||||||
|
public func importFromString() -> ThemeWallpaper {
|
||||||
|
if preset == nil, let image {
|
||||||
|
// Need to save image from string and to save its path
|
||||||
|
if let parsed = imageFromBase64(image),
|
||||||
|
let filename = saveWallpaperFile(image: parsed) {
|
||||||
|
var copy = self
|
||||||
|
copy.image = nil
|
||||||
|
copy.imageFile = filename
|
||||||
|
return copy
|
||||||
|
} else {
|
||||||
|
return ThemeWallpaper()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withFilledWallpaperBase64() -> ThemeWallpaper {
|
||||||
|
let aw = toAppWallpaper()
|
||||||
|
let type = aw.type
|
||||||
|
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
|
||||||
|
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
|
||||||
|
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
|
||||||
|
let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSizeSync(image, maxDataSize: 5_000_000) } else { nil }
|
||||||
|
return ThemeWallpaper (
|
||||||
|
preset: preset,
|
||||||
|
scale: scale,
|
||||||
|
scaleType: scaleType,
|
||||||
|
background: aw.background?.toReadableHex(),
|
||||||
|
tint: aw.tint?.toReadableHex(),
|
||||||
|
image: image,
|
||||||
|
imageFile: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ThemeModeOverride {
|
||||||
|
func removeSameColors(_ base: DefaultTheme, colorsToCompare tc: ThemeColors) -> ThemeModeOverride {
|
||||||
|
let wallpaperType = WallpaperType.from(wallpaper) ?? WallpaperType.empty
|
||||||
|
let w: ThemeWallpaper
|
||||||
|
switch wallpaperType {
|
||||||
|
case let WallpaperType.preset(filename, scale):
|
||||||
|
let p = PresetWallpaper.from(filename)
|
||||||
|
w = ThemeWallpaper(
|
||||||
|
preset: filename,
|
||||||
|
scale: scale ?? wallpaper?.scale,
|
||||||
|
scaleType: nil,
|
||||||
|
background: p?.background[base]?.toReadableHex(),
|
||||||
|
tint: p?.tint[base]?.toReadableHex(),
|
||||||
|
image: nil,
|
||||||
|
imageFile: nil
|
||||||
|
)
|
||||||
|
case WallpaperType.image:
|
||||||
|
w = ThemeWallpaper(
|
||||||
|
preset: nil,
|
||||||
|
scale: nil,
|
||||||
|
scaleType: WallpaperScaleType.fill,
|
||||||
|
background: Color.clear.toReadableHex(),
|
||||||
|
tint: Color.clear.toReadableHex(),
|
||||||
|
image: nil,
|
||||||
|
imageFile: nil
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
w = ThemeWallpaper()
|
||||||
|
}
|
||||||
|
let wallpaper: ThemeWallpaper? = if let wallpaper {
|
||||||
|
ThemeWallpaper(
|
||||||
|
preset: wallpaper.preset,
|
||||||
|
scale: wallpaper.scale != w.scale ? wallpaper.scale : nil,
|
||||||
|
scaleType: wallpaper.scaleType != w.scaleType ? wallpaper.scaleType : nil,
|
||||||
|
background: wallpaper.background != w.background ? wallpaper.background : nil,
|
||||||
|
tint: wallpaper.tint != w.tint ? wallpaper.tint : nil,
|
||||||
|
image: wallpaper.image,
|
||||||
|
imageFile: wallpaper.imageFile
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
return ThemeModeOverride(
|
||||||
|
mode: self.mode,
|
||||||
|
colors: ThemeColors(
|
||||||
|
primary: colors.primary != tc.primary ? colors.primary : nil,
|
||||||
|
primaryVariant: colors.primaryVariant != tc.primaryVariant ? colors.primaryVariant : nil,
|
||||||
|
secondary: colors.secondary != tc.secondary ? colors.secondary : nil,
|
||||||
|
secondaryVariant: colors.secondaryVariant != tc.secondaryVariant ? colors.secondaryVariant : nil,
|
||||||
|
background: colors.background != tc.background ? colors.background : nil,
|
||||||
|
surface: colors.surface != tc.surface ? colors.surface : nil,
|
||||||
|
title: colors.title != tc.title ? colors.title : nil,
|
||||||
|
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
|
||||||
|
sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
|
||||||
|
sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
|
||||||
|
receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
|
||||||
|
receivedQuote: colors.receivedQuote != tc.receivedQuote ? colors.receivedQuote : nil
|
||||||
|
),
|
||||||
|
wallpaper: wallpaper
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
//
|
||||||
|
// ThemeManager.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 03.06.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
class ThemeManager {
|
||||||
|
struct ActiveTheme: Equatable {
|
||||||
|
let name: String
|
||||||
|
let base: DefaultTheme
|
||||||
|
let colors: Colors
|
||||||
|
let appColors: AppColors
|
||||||
|
var wallpaper: AppWallpaper = AppWallpaper(background: nil, tint: nil, type: .empty)
|
||||||
|
|
||||||
|
func toAppTheme() -> AppTheme {
|
||||||
|
AppTheme(name: name, base: base, colors: colors, appColors: appColors, wallpaper: wallpaper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func systemDarkThemeColors() -> (Colors, DefaultTheme) {
|
||||||
|
switch systemDarkThemeDefault.get() {
|
||||||
|
case DefaultTheme.DARK.themeName: (DarkColorPalette, DefaultTheme.DARK)
|
||||||
|
case DefaultTheme.SIMPLEX.themeName: (SimplexColorPalette, DefaultTheme.SIMPLEX)
|
||||||
|
case DefaultTheme.BLACK.themeName: (BlackColorPalette, DefaultTheme.BLACK)
|
||||||
|
default: (SimplexColorPalette, DefaultTheme.SIMPLEX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nonSystemThemeName() -> String {
|
||||||
|
let themeName = currentThemeDefault.get()
|
||||||
|
return if themeName != DefaultTheme.SYSTEM_THEME_NAME {
|
||||||
|
themeName
|
||||||
|
} else {
|
||||||
|
systemInDarkThemeCurrently ? systemDarkThemeDefault.get() : DefaultTheme.LIGHT.themeName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? {
|
||||||
|
let nonSystemThemeName = nonSystemThemeName()
|
||||||
|
let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName]
|
||||||
|
return appSettingsTheme.getTheme(defaultThemeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultActiveTheme(_ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ThemeModeOverride {
|
||||||
|
let perUserTheme = !CurrentColors.colors.isLight ? perUserTheme?.dark : perUserTheme?.light
|
||||||
|
if let perUserTheme {
|
||||||
|
return perUserTheme
|
||||||
|
}
|
||||||
|
let defaultTheme = defaultActiveTheme(appSettingsTheme)
|
||||||
|
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
|
||||||
|
let themeName = currentThemeDefault.get()
|
||||||
|
let nonSystemThemeName = nonSystemThemeName()
|
||||||
|
let defaultTheme = defaultActiveTheme(appSettingsTheme)
|
||||||
|
|
||||||
|
let baseTheme = switch nonSystemThemeName {
|
||||||
|
case DefaultTheme.LIGHT.themeName: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
|
||||||
|
case DefaultTheme.DARK.themeName: ActiveTheme(name: DefaultTheme.DARK.themeName, base: DefaultTheme.DARK, colors: DarkColorPalette.clone(), appColors: DarkColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.DARK)))
|
||||||
|
case DefaultTheme.SIMPLEX.themeName: ActiveTheme(name: DefaultTheme.SIMPLEX.themeName, base: DefaultTheme.SIMPLEX, colors: SimplexColorPalette.clone(), appColors: SimplexColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.SIMPLEX)))
|
||||||
|
case DefaultTheme.BLACK.themeName: ActiveTheme(name: DefaultTheme.BLACK.themeName, base: DefaultTheme.BLACK, colors: BlackColorPalette.clone(), appColors: BlackColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.BLACK)))
|
||||||
|
default: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
|
||||||
|
}
|
||||||
|
|
||||||
|
let perUserTheme = baseTheme.colors.isLight ? perUserTheme?.light : perUserTheme?.dark
|
||||||
|
let theme = appSettingsTheme.sameTheme(themeOverridesForType ?? perChatTheme?.type ?? perUserTheme?.type ?? defaultTheme?.wallpaper?.toAppWallpaper().type, nonSystemThemeName) ?? defaultTheme
|
||||||
|
|
||||||
|
if theme == nil && perUserTheme == nil && perChatTheme == nil && themeOverridesForType == nil {
|
||||||
|
return ActiveTheme(name: themeName, base: baseTheme.base, colors: baseTheme.colors, appColors: baseTheme.appColors, wallpaper: baseTheme.wallpaper)
|
||||||
|
}
|
||||||
|
let presetWallpaperTheme: ThemeColors? = if let themeOverridesForType, case let WallpaperType.preset(filename, _) = themeOverridesForType {
|
||||||
|
PresetWallpaper.from(filename)?.colors[baseTheme.base]
|
||||||
|
} else if let wallpaper = perChatTheme?.wallpaper {
|
||||||
|
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
|
||||||
|
} else if let wallpaper = perUserTheme?.wallpaper {
|
||||||
|
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
|
||||||
|
} else {
|
||||||
|
if let preset = theme?.wallpaper?.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
let themeOrEmpty = theme ?? ThemeOverrides(base: baseTheme.base)
|
||||||
|
let colors = themeOrEmpty.toColors(themeOrEmpty.base, perChatTheme?.colors, perUserTheme?.colors, presetWallpaperTheme)
|
||||||
|
return ActiveTheme(
|
||||||
|
name: themeName,
|
||||||
|
base: baseTheme.base,
|
||||||
|
colors: colors,
|
||||||
|
appColors: themeOrEmpty.toAppColors(themeOrEmpty.base, perChatTheme?.colors, perChatTheme?.type, perUserTheme?.colors, perUserTheme?.type, presetWallpaperTheme),
|
||||||
|
wallpaper: themeOrEmpty.toAppWallpaper(themeOverridesForType, perChatTheme, perUserTheme, colors.background)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides {
|
||||||
|
let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get())
|
||||||
|
let wType = current.wallpaper.type
|
||||||
|
let wBackground = current.wallpaper.background
|
||||||
|
let wTint = current.wallpaper.tint
|
||||||
|
let w: ThemeWallpaper? = if case WallpaperType.empty = wType {
|
||||||
|
nil
|
||||||
|
} else {
|
||||||
|
ThemeWallpaper.from(wType, wBackground?.toReadableHex(), wTint?.toReadableHex()).withFilledWallpaperBase64()
|
||||||
|
}
|
||||||
|
return ThemeOverrides(
|
||||||
|
themeId: "",
|
||||||
|
base: current.base,
|
||||||
|
colors: ThemeColors.from(current.colors, current.appColors),
|
||||||
|
wallpaper: w
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func applyTheme(_ theme: String) {
|
||||||
|
currentThemeDefault.set(theme)
|
||||||
|
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
AppTheme.shared.updateFromCurrentColors()
|
||||||
|
let tint = UIColor(CurrentColors.colors.primary)
|
||||||
|
if SceneDelegate.windowStatic?.tintColor != tint {
|
||||||
|
SceneDelegate.windowStatic?.tintColor = tint
|
||||||
|
}
|
||||||
|
// applyNavigationBarColors(CurrentColors.toAppTheme())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func adjustWindowStyle() {
|
||||||
|
let style = switch currentThemeDefault.get() {
|
||||||
|
case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light
|
||||||
|
case DefaultTheme.SYSTEM_THEME_NAME: UIUserInterfaceStyle.unspecified
|
||||||
|
default: UIUserInterfaceStyle.dark
|
||||||
|
}
|
||||||
|
if SceneDelegate.windowStatic?.overrideUserInterfaceStyle != style {
|
||||||
|
SceneDelegate.windowStatic?.overrideUserInterfaceStyle = style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// static func applyNavigationBarColors(_ theme: AppTheme) {
|
||||||
|
// let baseColors = switch theme.base {
|
||||||
|
// case DefaultTheme.LIGHT: LightColorPaletteApp
|
||||||
|
// case DefaultTheme.DARK: DarkColorPaletteApp
|
||||||
|
// case DefaultTheme.SIMPLEX: SimplexColorPaletteApp
|
||||||
|
// case DefaultTheme.BLACK: BlackColorPaletteApp
|
||||||
|
// }
|
||||||
|
// let isDefaultColor = baseColors.title == theme.appColors.title
|
||||||
|
//
|
||||||
|
// let title = UIColor(theme.appColors.title)
|
||||||
|
// if !isDefaultColor && UINavigationBar.appearance().titleTextAttributes?.first as? UIColor != title {
|
||||||
|
// UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: title]
|
||||||
|
// UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: title]
|
||||||
|
// } else {
|
||||||
|
// UINavigationBar.appearance().titleTextAttributes = nil
|
||||||
|
// UINavigationBar.appearance().largeTitleTextAttributes = nil
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
static func changeDarkTheme(_ theme: String) {
|
||||||
|
systemDarkThemeDefault.set(theme)
|
||||||
|
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
AppTheme.shared.updateFromCurrentColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||||
|
let nonSystemThemeName = baseTheme.themeName
|
||||||
|
let pref = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
let themeId = currentThemeIdsDefault.get()[nonSystemThemeName]
|
||||||
|
let prevValue = overrides.getTheme(themeId) ?? ThemeOverrides(base: baseTheme)
|
||||||
|
pref.set(overrides.replace(prevValue.withUpdatedColor(name, color?.toReadableHex())))
|
||||||
|
var themeIds = currentThemeIdsDefault.get()
|
||||||
|
themeIds[nonSystemThemeName] = prevValue.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func applyThemeColor(name: ThemeColor, color: Color? = nil, pref: Binding<ThemeModeOverride>) {
|
||||||
|
pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) {
|
||||||
|
let nonSystemThemeName = baseTheme.themeName
|
||||||
|
let pref = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
let theme = overrides.sameTheme(type, baseTheme.themeName)
|
||||||
|
var prevValue = theme ?? ThemeOverrides(base: baseTheme)
|
||||||
|
prevValue.wallpaper = if let type {
|
||||||
|
if case WallpaperType.empty = type {
|
||||||
|
nil as ThemeWallpaper?
|
||||||
|
} else {
|
||||||
|
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
pref.set(overrides.replace(prevValue))
|
||||||
|
var themeIds = currentThemeIdsDefault.get()
|
||||||
|
themeIds[nonSystemThemeName] = prevValue.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {
|
||||||
|
let overrides = themeOverridesDefault.get()
|
||||||
|
let sameWallpaper: ThemeWallpaper? = if let wallpaper = lowerLevelOverride?.wallpaper, lowerLevelOverride?.type?.sameType(type) == true {
|
||||||
|
wallpaper
|
||||||
|
} else {
|
||||||
|
overrides.sameTheme(type, CurrentColors.base.themeName)?.wallpaper
|
||||||
|
}
|
||||||
|
guard let sameWallpaper else {
|
||||||
|
if let type {
|
||||||
|
var w: ThemeWallpaper = ThemeWallpaper.from(type, nil, nil)
|
||||||
|
w.scale = nil
|
||||||
|
w.scaleType = nil
|
||||||
|
w.background = nil
|
||||||
|
w.tint = nil
|
||||||
|
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: w)
|
||||||
|
} else {
|
||||||
|
// Make an empty wallpaper to override any top level ones
|
||||||
|
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: ThemeWallpaper())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var type = sameWallpaper.toAppWallpaper().type
|
||||||
|
if case let WallpaperType.image(filename, scale, scaleType) = type, sameWallpaper.imageFile == filename {
|
||||||
|
// same image file. Needs to be copied first in order to be able to remove the file once it's not needed anymore without affecting main theme override
|
||||||
|
if let filename = saveWallpaperFile(url: getWallpaperFilePath(filename)) {
|
||||||
|
type = WallpaperType.image(filename, scale, scaleType)
|
||||||
|
} else {
|
||||||
|
logger.error("Error while copying wallpaper from global overrides to chat overrides")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var prevValue = pref.wrappedValue
|
||||||
|
var w = ThemeWallpaper.from(type, nil, nil)
|
||||||
|
w.scale = nil
|
||||||
|
w.scaleType = nil
|
||||||
|
w.background = nil
|
||||||
|
w.tint = nil
|
||||||
|
prevValue.colors = ThemeColors()
|
||||||
|
prevValue.wallpaper = w
|
||||||
|
pref.wrappedValue = prevValue
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
static func applyWallpaper(_ type: WallpaperType?, _ pref: Binding<ThemeModeOverride>) {
|
||||||
|
var prevValue = pref.wrappedValue
|
||||||
|
prevValue.wallpaper = if let type {
|
||||||
|
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
pref.wrappedValue = prevValue
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||||
|
let wallpaper = theme.wallpaper?.importFromString()
|
||||||
|
let nonSystemThemeName = theme.base.themeName
|
||||||
|
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
var prevValue = overrides.getTheme(nil, wallpaper?.toAppWallpaper().type, theme.base) ?? ThemeOverrides(base: theme.base)
|
||||||
|
if let imageFile = prevValue.wallpaper?.imageFile {
|
||||||
|
try? FileManager.default.removeItem(at: getWallpaperFilePath(imageFile))
|
||||||
|
}
|
||||||
|
prevValue.base = theme.base
|
||||||
|
prevValue.colors = theme.colors
|
||||||
|
prevValue.wallpaper = wallpaper
|
||||||
|
pref.set(overrides.replace(prevValue))
|
||||||
|
currentThemeDefault.set(nonSystemThemeName)
|
||||||
|
var currentThemeIds = currentThemeIdsDefault.get()
|
||||||
|
currentThemeIds[nonSystemThemeName] = prevValue.themeId
|
||||||
|
currentThemeIdsDefault.set(currentThemeIds)
|
||||||
|
applyTheme(nonSystemThemeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||||
|
let nonSystemThemeName = nonSystemThemeName()
|
||||||
|
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
guard let themeId = currentThemeIdsDefault.get()[nonSystemThemeName],
|
||||||
|
var prevValue = overrides.getTheme(themeId)
|
||||||
|
else { return }
|
||||||
|
prevValue.colors = ThemeColors()
|
||||||
|
prevValue.wallpaper?.background = nil
|
||||||
|
prevValue.wallpaper?.tint = nil
|
||||||
|
pref.set(overrides.replace(prevValue))
|
||||||
|
applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resetAllThemeColors(_ pref: Binding<ThemeModeOverride>) {
|
||||||
|
var prevValue = pref.wrappedValue
|
||||||
|
prevValue.colors = ThemeColors()
|
||||||
|
prevValue.wallpaper?.background = nil
|
||||||
|
prevValue.wallpaper?.tint = nil
|
||||||
|
pref.wrappedValue = prevValue
|
||||||
|
}
|
||||||
|
|
||||||
|
static func removeTheme(_ themeId: String?) {
|
||||||
|
var themes = themeOverridesDefault.get().map { $0 }
|
||||||
|
themes.removeAll(where: { $0.themeId == themeId })
|
||||||
|
themeOverridesDefault.set(themes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,8 @@ struct ActiveCallView: View {
|
|||||||
@ObservedObject var call: Call
|
@ObservedObject var call: Call
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@State private var client: WebRTCClient? = nil
|
@State private var client: WebRTCClient? = nil
|
||||||
@State private var activeCall: WebRTCClient.Call? = nil
|
|
||||||
@State private var localRendererAspectRatio: CGFloat? = nil
|
@State private var localRendererAspectRatio: CGFloat? = nil
|
||||||
|
@State var remoteContentMode: UIView.ContentMode = .scaleAspectFill
|
||||||
@Binding var canConnectCall: Bool
|
@Binding var canConnectCall: Bool
|
||||||
@State var prevColorScheme: ColorScheme = .dark
|
@State var prevColorScheme: ColorScheme = .dark
|
||||||
@State var pipShown = false
|
@State var pipShown = false
|
||||||
@@ -27,24 +27,39 @@ struct ActiveCallView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
|
if let client = client, call.hasVideo {
|
||||||
GeometryReader { g in
|
GeometryReader { g in
|
||||||
let width = g.size.width * 0.3
|
let width = g.size.width * 0.3
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown)
|
|
||||||
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
|
|
||||||
.cornerRadius(10)
|
|
||||||
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
|
|
||||||
.padding([.top, .trailing], 17)
|
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
|
// For some reason, when the view in GeometryReader and ZStack is visible, it steals clicks on a back button, so showing something on top like this with background color helps (.clear color doesn't work)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.primary.opacity(0.000001))
|
.background(Color.primary.opacity(0.000001))
|
||||||
|
|
||||||
|
CallViewRemote(client: client, call: call, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, contentMode: $remoteContentMode, pipShown: $pipShown)
|
||||||
|
.onTapGesture {
|
||||||
|
remoteContentMode = remoteContentMode == .scaleAspectFill ? .scaleAspectFit : .scaleAspectFill
|
||||||
|
}
|
||||||
|
|
||||||
|
Group {
|
||||||
|
let localVideoTrack = client.activeCall?.localVideoTrack ?? client.notConnectedCall?.localCameraAndTrack?.1
|
||||||
|
if localVideoTrack != nil {
|
||||||
|
CallViewLocal(client: client, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
|
||||||
|
.onDisappear {
|
||||||
|
localRendererAspectRatio = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Rectangle().fill(.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cornerRadius(10)
|
||||||
|
.frame(width: width, height: localRendererAspectRatio == nil ? (g.size.width < g.size.height ? width * 1.33 : width / 1.33) : width / (localRendererAspectRatio ?? 1))
|
||||||
|
.padding([.top, .trailing], 17)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
|
if let call = m.activeCall, let client = client, (!pipShown || !call.hasVideo) {
|
||||||
ActiveCallOverlay(call: call, client: client)
|
ActiveCallOverlay(call: call, client: client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,6 +69,9 @@ struct ActiveCallView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
|
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
|
||||||
AppDelegate.keepScreenOn(true)
|
AppDelegate.keepScreenOn(true)
|
||||||
|
Task {
|
||||||
|
await askRequiredPermissions()
|
||||||
|
}
|
||||||
createWebRTCClient()
|
createWebRTCClient()
|
||||||
dismissAllSheets()
|
dismissAllSheets()
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
@@ -84,7 +102,7 @@ struct ActiveCallView: View {
|
|||||||
|
|
||||||
private func createWebRTCClient() {
|
private func createWebRTCClient() {
|
||||||
if client == nil && canConnectCall {
|
if client == nil && canConnectCall {
|
||||||
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
|
client = WebRTCClient({ msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
|
||||||
Task {
|
Task {
|
||||||
await m.callCommand.setClient(client)
|
await m.callCommand.setClient(client)
|
||||||
}
|
}
|
||||||
@@ -99,7 +117,7 @@ struct ActiveCallView: View {
|
|||||||
logger.debug("ActiveCallView: response \(msg.resp.respType)")
|
logger.debug("ActiveCallView: response \(msg.resp.respType)")
|
||||||
switch msg.resp {
|
switch msg.resp {
|
||||||
case let .capabilities(capabilities):
|
case let .capabilities(capabilities):
|
||||||
let callType = CallType(media: call.localMedia, capabilities: capabilities)
|
let callType = CallType(media: call.initialCallType, capabilities: capabilities)
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await apiSendCallInvitation(call.contact, callType)
|
try await apiSendCallInvitation(call.contact, callType)
|
||||||
@@ -110,7 +128,7 @@ struct ActiveCallView: View {
|
|||||||
call.callState = .invitationSent
|
call.callState = .invitationSent
|
||||||
call.localCapabilities = capabilities
|
call.localCapabilities = capabilities
|
||||||
}
|
}
|
||||||
if call.supportsVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
|
if call.hasVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
|
||||||
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||||
}
|
}
|
||||||
CallSoundsPlayer.shared.startConnectingCallSound()
|
CallSoundsPlayer.shared.startConnectingCallSound()
|
||||||
@@ -120,7 +138,7 @@ struct ActiveCallView: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
||||||
media: call.localMedia, capabilities: capabilities)
|
media: call.initialCallType, capabilities: capabilities)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("apiSendCallOffer \(responseError(error))")
|
logger.error("apiSendCallOffer \(responseError(error))")
|
||||||
}
|
}
|
||||||
@@ -164,6 +182,9 @@ struct ActiveCallView: View {
|
|||||||
}
|
}
|
||||||
if state.connectionState == "closed" {
|
if state.connectionState == "closed" {
|
||||||
closeCallView(client)
|
closeCallView(client)
|
||||||
|
if let callUUID = m.activeCall?.callUUID {
|
||||||
|
CallController.shared.endCall(callUUID: callUUID)
|
||||||
|
}
|
||||||
m.activeCall = nil
|
m.activeCall = nil
|
||||||
m.activeCallViewIsCollapsed = false
|
m.activeCallViewIsCollapsed = false
|
||||||
}
|
}
|
||||||
@@ -182,10 +203,18 @@ struct ActiveCallView: View {
|
|||||||
CallSoundsPlayer.shared.vibrate(long: false)
|
CallSoundsPlayer.shared.vibrate(long: false)
|
||||||
wasConnected = true
|
wasConnected = true
|
||||||
}
|
}
|
||||||
|
case let .peerMedia(source, enabled):
|
||||||
|
switch source {
|
||||||
|
case .mic: call.peerMediaSources.mic = enabled
|
||||||
|
case .camera: call.peerMediaSources.camera = enabled
|
||||||
|
case .screenAudio: call.peerMediaSources.screenAudio = enabled
|
||||||
|
case .screenVideo: call.peerMediaSources.screenVideo = enabled
|
||||||
|
case .unknown: ()
|
||||||
|
}
|
||||||
case .ended:
|
case .ended:
|
||||||
closeCallView(client)
|
closeCallView(client)
|
||||||
call.callState = .ended
|
call.callState = .ended
|
||||||
if let uuid = call.callkitUUID {
|
if let uuid = call.callUUID {
|
||||||
CallController.shared.endCall(callUUID: uuid)
|
CallController.shared.endCall(callUUID: uuid)
|
||||||
}
|
}
|
||||||
case .ok:
|
case .ok:
|
||||||
@@ -214,16 +243,38 @@ struct ActiveCallView: View {
|
|||||||
ChatReceiver.shared.messagesChannel = nil
|
ChatReceiver.shared.messagesChannel = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if case let .chatItemStatusUpdated(_, msg) = msg,
|
if case let .chatItemsStatusesUpdated(_, chatItems) = msg,
|
||||||
msg.chatInfo.id == call.contact.id,
|
chatItems.contains(where: { ci in
|
||||||
case .sndCall = msg.chatItem.content,
|
ci.chatInfo.id == call.contact.id &&
|
||||||
case .sndRcvd = msg.chatItem.meta.itemStatus {
|
ci.chatItem.content.isSndCall &&
|
||||||
|
ci.chatItem.meta.itemStatus.isSndRcvd
|
||||||
|
}) {
|
||||||
CallSoundsPlayer.shared.startInCallSound()
|
CallSoundsPlayer.shared.startInCallSound()
|
||||||
ChatReceiver.shared.messagesChannel = nil
|
ChatReceiver.shared.messagesChannel = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func askRequiredPermissions() async {
|
||||||
|
let mic = await WebRTCClient.isAuthorized(for: .audio)
|
||||||
|
await MainActor.run {
|
||||||
|
call.localMediaSources.mic = mic
|
||||||
|
}
|
||||||
|
let cameraAuthorized = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||||
|
var camera = call.initialCallType == .audio || cameraAuthorized
|
||||||
|
if call.initialCallType == .video && !cameraAuthorized {
|
||||||
|
camera = await WebRTCClient.isAuthorized(for: .video)
|
||||||
|
await MainActor.run {
|
||||||
|
if camera, let client {
|
||||||
|
client.setCameraEnabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !mic || !camera {
|
||||||
|
WebRTCClient.showUnauthorizedAlert(for: !mic ? .audio : .video)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func closeCallView(_ client: WebRTCClient) {
|
private func closeCallView(_ client: WebRTCClient) {
|
||||||
if m.activeCall != nil {
|
if m.activeCall != nil {
|
||||||
m.showCallView = false
|
m.showCallView = false
|
||||||
@@ -239,44 +290,16 @@ struct ActiveCallOverlay: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
switch call.localMedia {
|
switch call.hasVideo {
|
||||||
case .video:
|
case true:
|
||||||
videoCallInfoView(call)
|
videoCallInfoView(call)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.opacity(0.8)
|
.opacity(0.8)
|
||||||
.padding()
|
.padding(.horizontal)
|
||||||
|
// Fixed vertical padding required for preserving position of buttons row when changing audio-to-video and back in landscape orientation.
|
||||||
Spacer()
|
// Otherwise, bigger padding is added by SwiftUI when switching call types
|
||||||
|
.padding(.vertical, 10)
|
||||||
HStack {
|
case false:
|
||||||
toggleAudioButton()
|
|
||||||
Spacer()
|
|
||||||
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
|
|
||||||
toggleSpeakerButton()
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
} else if call.hasMedia {
|
|
||||||
AudioDevicePicker()
|
|
||||||
.scaleEffect(2)
|
|
||||||
.frame(maxWidth: 40, maxHeight: 40)
|
|
||||||
} else {
|
|
||||||
Color.clear.frame(width: 40, height: 40)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
endCallButton()
|
|
||||||
Spacer()
|
|
||||||
if call.videoEnabled {
|
|
||||||
flipCameraButton()
|
|
||||||
} else {
|
|
||||||
Color.clear.frame(width: 40, height: 40)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
toggleVideoButton()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
|
|
||||||
case .audio:
|
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
Button {
|
Button {
|
||||||
chatModel.activeCallViewIsCollapsed = true
|
chatModel.activeCallViewIsCollapsed = true
|
||||||
@@ -291,35 +314,32 @@ struct ActiveCallOverlay: View {
|
|||||||
}
|
}
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.opacity(0.8)
|
.opacity(0.8)
|
||||||
.padding()
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 10)
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
ZStack(alignment: .bottom) {
|
|
||||||
toggleAudioButton()
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
endCallButton()
|
|
||||||
// Check if the only input is microphone. And in this case show toggle button,
|
|
||||||
// If there are more inputs, it probably means something like bluetooth headphones are available
|
|
||||||
// and in this case show iOS button for choosing different output.
|
|
||||||
// There is no way to get available outputs, only inputs
|
|
||||||
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
|
|
||||||
toggleSpeakerButton()
|
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
} else if call.hasMedia {
|
|
||||||
AudioDevicePicker()
|
|
||||||
.scaleEffect(2)
|
|
||||||
.frame(maxWidth: 50, maxHeight: 40)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
} else {
|
|
||||||
Color.clear.frame(width: 50, height: 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.bottom, 60)
|
|
||||||
.padding(.horizontal, 48)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
toggleMicButton()
|
||||||
|
Spacer()
|
||||||
|
audioDeviceButton()
|
||||||
|
Spacer()
|
||||||
|
endCallButton()
|
||||||
|
Spacer()
|
||||||
|
if call.localMediaSources.camera {
|
||||||
|
flipCameraButton()
|
||||||
|
} else {
|
||||||
|
Color.clear.frame(width: 60, height: 60)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
toggleCameraButton()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.frame(maxWidth: 440, alignment: .center)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -381,36 +401,54 @@ struct ActiveCallOverlay: View {
|
|||||||
|
|
||||||
private func endCallButton() -> some View {
|
private func endCallButton() -> some View {
|
||||||
let cc = CallController.shared
|
let cc = CallController.shared
|
||||||
return callButton("phone.down.fill", width: 60, height: 60) {
|
return callButton("phone.down.fill", .red, padding: 10) {
|
||||||
if let uuid = call.callkitUUID {
|
if let uuid = call.callUUID {
|
||||||
cc.endCall(callUUID: uuid)
|
cc.endCall(callUUID: uuid)
|
||||||
} else {
|
} else {
|
||||||
cc.endCall(call: call) {}
|
cc.endCall(call: call) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleAudioButton() -> some View {
|
private func toggleMicButton() -> some View {
|
||||||
controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
|
controlButton(call, call.localMediaSources.mic ? "mic.fill" : "mic.slash", padding: 14) {
|
||||||
Task {
|
Task {
|
||||||
client.setAudioEnabled(!call.audioEnabled)
|
if await WebRTCClient.isAuthorized(for: .audio) {
|
||||||
DispatchQueue.main.async {
|
client.setAudioEnabled(!call.localMediaSources.mic)
|
||||||
call.audioEnabled = !call.audioEnabled
|
} else { WebRTCClient.showUnauthorizedAlert(for: .audio) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func audioDeviceButton() -> some View {
|
||||||
|
// Check if the only input is microphone. And in this case show toggle button,
|
||||||
|
// If there are more inputs, it probably means something like bluetooth headphones are available
|
||||||
|
// and in this case show iOS button for choosing different output.
|
||||||
|
// There is no way to get available outputs, only inputs
|
||||||
|
Group {
|
||||||
|
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
|
||||||
|
toggleSpeakerButton()
|
||||||
|
} else {
|
||||||
|
audioDevicePickerButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: call.localMediaSources.hasVideo) { hasVideo in
|
||||||
|
let current = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType
|
||||||
|
let speakerEnabled = current == .builtInSpeaker
|
||||||
|
let receiverEnabled = current == .builtInReceiver
|
||||||
|
// react automatically only when receiver were selected, otherwise keep an external device selected
|
||||||
|
if !speakerEnabled && hasVideo && receiverEnabled {
|
||||||
|
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled, skipExternalDevice: true)
|
||||||
|
call.speakerEnabled = !speakerEnabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleSpeakerButton() -> some View {
|
private func toggleSpeakerButton() -> some View {
|
||||||
controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
|
controlButton(call, !call.peerMediaSources.mic ? "speaker.slash" : call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill", padding: !call.peerMediaSources.mic ? 16 : call.speakerEnabled ? 15 : 17) {
|
||||||
Task {
|
let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
|
||||||
let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
|
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
|
||||||
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
|
call.speakerEnabled = !speakerEnabled
|
||||||
DispatchQueue.main.async {
|
|
||||||
call.speakerEnabled = !speakerEnabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
deviceManager.call = call
|
deviceManager.call = call
|
||||||
@@ -418,53 +456,67 @@ struct ActiveCallOverlay: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleVideoButton() -> some View {
|
private func toggleCameraButton() -> some View {
|
||||||
controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
|
controlButton(call, call.localMediaSources.camera ? "video.fill" : "video.slash", padding: call.localMediaSources.camera ? 16 : 14) {
|
||||||
Task {
|
Task {
|
||||||
client.setVideoEnabled(!call.videoEnabled)
|
if await WebRTCClient.isAuthorized(for: .video) {
|
||||||
DispatchQueue.main.async {
|
client.setCameraEnabled(!call.localMediaSources.camera)
|
||||||
call.videoEnabled = !call.videoEnabled
|
} else { WebRTCClient.showUnauthorizedAlert(for: .video) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func flipCameraButton() -> some View {
|
@ViewBuilder private func flipCameraButton() -> some View {
|
||||||
controlButton(call, "arrow.triangle.2.circlepath") {
|
controlButton(call, "arrow.triangle.2.circlepath", padding: 12) {
|
||||||
Task {
|
Task {
|
||||||
client.flipCamera()
|
if await WebRTCClient.isAuthorized(for: .video) {
|
||||||
|
client.flipCamera()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
|
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
|
||||||
if call.hasMedia {
|
callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform)
|
||||||
callButton(imageName, width: 50, height: 38, perform)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.opacity(0.85)
|
|
||||||
} else {
|
|
||||||
Color.clear.frame(width: 50, height: 38)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> some View {
|
@ViewBuilder private func audioDevicePickerButton() -> some View {
|
||||||
|
AudioDevicePicker()
|
||||||
|
.opacity(0.8)
|
||||||
|
.scaleEffect(2)
|
||||||
|
.padding(10)
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
.background(call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2))
|
||||||
|
.clipShape(.circle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func callButton(_ imageName: String, _ background: Color, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
|
||||||
Button {
|
Button {
|
||||||
perform()
|
perform()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: imageName)
|
Image(systemName: imageName)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(maxWidth: width, maxHeight: height)
|
.padding(padding)
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
.background(background)
|
||||||
}
|
}
|
||||||
|
.foregroundColor(whiteColorWithAlpha)
|
||||||
|
.clipShape(.circle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var whiteColorWithAlpha: Color {
|
||||||
|
get { Color(red: 204 / 255, green: 204 / 255, blue: 204 / 255) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ActiveCallOverlay_Previews: PreviewProvider {
|
struct ActiveCallOverlay_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group{
|
Group{
|
||||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .video), client: WebRTCClient({ _ in }, Binding.constant(nil)))
|
||||||
.background(.black)
|
.background(.black)
|
||||||
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
|
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .audio), client: WebRTCClient({ _ in }, Binding.constant(nil)))
|
||||||
.background(.black)
|
.background(.black)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// MPVolumeView.swift
|
// MPVolumeView.swift
|
||||||
// SimpleX (iOS)
|
// SimpleX (iOS)
|
||||||
//
|
//
|
||||||
// Created by Stanislav on 24.04.2024.
|
// Created by Avently on 24.04.2024.
|
||||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
|
|
||||||
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||||
logger.debug("CallController.provider CXStartCallAction")
|
logger.debug("CallController.provider CXStartCallAction")
|
||||||
if callManager.startOutgoingCall(callUUID: action.callUUID) {
|
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
|
||||||
} else {
|
} else {
|
||||||
@@ -61,12 +61,30 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
|
|
||||||
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||||
logger.debug("CallController.provider CXAnswerCallAction")
|
logger.debug("CallController.provider CXAnswerCallAction")
|
||||||
if callManager.answerIncomingCall(callUUID: action.callUUID) {
|
Task {
|
||||||
// WebRTC call should be in connected state to fulfill.
|
let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500)
|
||||||
// Otherwise no audio and mic working on lockscreen
|
logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))")
|
||||||
fulfillOnConnect = action
|
if !chatIsReady {
|
||||||
} else {
|
action.fail()
|
||||||
action.fail()
|
return
|
||||||
|
}
|
||||||
|
if !ChatModel.shared.callInvitations.values.contains(where: { inv in inv.callUUID == action.callUUID.uuidString.lowercased() }) {
|
||||||
|
try? await justRefreshCallInvitations()
|
||||||
|
logger.debug("CallController: updated call invitations chat")
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
logger.debug("CallController.provider will answer on call")
|
||||||
|
|
||||||
|
if callManager.answerIncomingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||||
|
logger.debug("CallController.provider answered on call")
|
||||||
|
// WebRTC call should be in connected state to fulfill.
|
||||||
|
// Otherwise no audio and mic working on lockscreen
|
||||||
|
fulfillOnConnect = action
|
||||||
|
} else {
|
||||||
|
logger.debug("CallController.provider will fail the call")
|
||||||
|
action.fail()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +93,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
// Should be nil here if connection was in connected state
|
// Should be nil here if connection was in connected state
|
||||||
fulfillOnConnect?.fail()
|
fulfillOnConnect?.fail()
|
||||||
fulfillOnConnect = nil
|
fulfillOnConnect = nil
|
||||||
callManager.endCall(callUUID: action.callUUID) { ok in
|
callManager.endCall(callUUID: action.callUUID.uuidString.lowercased()) { ok in
|
||||||
if ok {
|
if ok {
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
} else {
|
} else {
|
||||||
@@ -86,7 +104,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
}
|
}
|
||||||
|
|
||||||
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||||
if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) {
|
if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
} else {
|
} else {
|
||||||
action.fail()
|
action.fail()
|
||||||
@@ -103,8 +121,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
|
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
|
||||||
RTCAudioSession.sharedInstance().isAudioEnabled = true
|
RTCAudioSession.sharedInstance().isAudioEnabled = true
|
||||||
do {
|
do {
|
||||||
let supportsVideo = ChatModel.shared.activeCall?.supportsVideo == true
|
let hasVideo = ChatModel.shared.activeCall?.hasVideo == true
|
||||||
if supportsVideo {
|
if hasVideo {
|
||||||
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||||
} else {
|
} else {
|
||||||
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||||
@@ -115,7 +133,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000)
|
try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000)
|
||||||
if let preferred = audioSession.preferredInputDevice() {
|
if let preferred = audioSession.preferredInputDevice() {
|
||||||
await MainActor.run { try? audioSession.setPreferredInput(preferred) }
|
await MainActor.run { try? audioSession.setPreferredInput(preferred) }
|
||||||
} else if supportsVideo {
|
} else if hasVideo {
|
||||||
await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
|
await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +174,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func waitUntilChatStarted(timeoutMs: UInt64, stepMs: UInt64) async -> Bool {
|
||||||
|
logger.debug("CallController waiting until chat started")
|
||||||
|
var t: UInt64 = 0
|
||||||
|
repeat {
|
||||||
|
if ChatModel.shared.chatInitialized, ChatModel.shared.chatRunning == true, case .active = AppChatState.shared.value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
|
||||||
|
t += stepMs
|
||||||
|
} while t < timeoutMs
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
@objc(pushRegistry:didUpdatePushCredentials:forType:)
|
@objc(pushRegistry:didUpdatePushCredentials:forType:)
|
||||||
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
|
||||||
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
|
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
|
||||||
@@ -171,32 +202,19 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
self.reportExpiredCall(payload: payload, completion)
|
self.reportExpiredCall(payload: payload, completion)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!ChatModel.shared.chatInitialized) {
|
|
||||||
logger.debug("CallController: initializing chat")
|
|
||||||
do {
|
|
||||||
try initializeChat(start: true, refreshInvitations: false)
|
|
||||||
} catch let error {
|
|
||||||
logger.error("CallController: initializing chat error: \(error)")
|
|
||||||
self.reportExpiredCall(payload: payload, completion)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug("CallController: initialized chat")
|
|
||||||
startChatForCall()
|
|
||||||
logger.debug("CallController: started chat")
|
|
||||||
self.shouldSuspendChat = true
|
|
||||||
// There are no invitations in the model, as it was processed by NSE
|
|
||||||
_ = try? justRefreshCallInvitations()
|
|
||||||
logger.debug("CallController: updated call invitations chat")
|
|
||||||
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
|
|
||||||
// Extract the call information from the push notification payload
|
// Extract the call information from the push notification payload
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||||
let invitation = m.callInvitations[contactId] {
|
let displayName = payload.dictionaryPayload["displayName"] as? String,
|
||||||
let update = self.cxCallUpdate(invitation: invitation)
|
let callUUID = payload.dictionaryPayload["callUUID"] as? String,
|
||||||
if let uuid = invitation.callkitUUID {
|
let uuid = UUID(uuidString: callUUID),
|
||||||
|
let callTsInterval = payload.dictionaryPayload["callTs"] as? TimeInterval,
|
||||||
|
let mediaStr = payload.dictionaryPayload["media"] as? String,
|
||||||
|
let media = CallMediaType(rawValue: mediaStr) {
|
||||||
|
let update = self.cxCallUpdate(contactId, displayName, media)
|
||||||
|
let callTs = Date(timeIntervalSince1970: callTsInterval)
|
||||||
|
if callTs.timeIntervalSinceNow >= -180 {
|
||||||
logger.debug("CallController: report pushkit call via CallKit")
|
logger.debug("CallController: report pushkit call via CallKit")
|
||||||
let update = self.cxCallUpdate(invitation: invitation)
|
|
||||||
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
self.provider.reportNewIncomingCall(with: uuid, update: update) { error in
|
||||||
if error != nil {
|
if error != nil {
|
||||||
m.callInvitations.removeValue(forKey: contactId)
|
m.callInvitations.removeValue(forKey: contactId)
|
||||||
@@ -205,11 +223,31 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
completion()
|
completion()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
logger.debug("CallController will expire call 1")
|
||||||
self.reportExpiredCall(update: update, completion)
|
self.reportExpiredCall(update: update, completion)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
logger.debug("CallController will expire call 2")
|
||||||
self.reportExpiredCall(payload: payload, completion)
|
self.reportExpiredCall(payload: payload, completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
|
||||||
|
if (!ChatModel.shared.chatInitialized) {
|
||||||
|
logger.debug("CallController: initializing chat")
|
||||||
|
do {
|
||||||
|
try initializeChat(start: true, refreshInvitations: false)
|
||||||
|
} catch let error {
|
||||||
|
logger.error("CallController: initializing chat error: \(error)")
|
||||||
|
if let call = ChatModel.shared.activeCall {
|
||||||
|
self.endCall(call: call, completed: completion)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug("CallController: initialized chat")
|
||||||
|
startChatForCall()
|
||||||
|
logger.debug("CallController: started chat")
|
||||||
|
self.shouldSuspendChat = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// This function fulfils the requirement to always report a call when PushKit notification is received,
|
// This function fulfils the requirement to always report a call when PushKit notification is received,
|
||||||
@@ -239,8 +277,8 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
|
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
|
||||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||||
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
if invitation.callTs.timeIntervalSinceNow >= -180 {
|
||||||
let update = cxCallUpdate(invitation: invitation)
|
let update = cxCallUpdate(invitation: invitation)
|
||||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||||
@@ -261,6 +299,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
return update
|
return update
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func cxCallUpdate(_ contactId: String, _ displayName: String, _ media: CallMediaType) -> CXCallUpdate {
|
||||||
|
let update = CXCallUpdate()
|
||||||
|
update.remoteHandle = CXHandle(type: .generic, value: contactId)
|
||||||
|
update.hasVideo = media == .video
|
||||||
|
update.localizedCallerName = displayName
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
|
func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||||
logger.debug("CallController: reporting incoming call connected")
|
logger.debug("CallController: reporting incoming call connected")
|
||||||
if CallController.useCallKit() {
|
if CallController.useCallKit() {
|
||||||
@@ -272,14 +318,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
|
|
||||||
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||||
logger.debug("CallController: reporting outgoing call connected")
|
logger.debug("CallController: reporting outgoing call connected")
|
||||||
if CallController.useCallKit(), let uuid = call.callkitUUID {
|
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||||
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
|
provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
func reportCallRemoteEnded(invitation: RcvCallInvitation) {
|
||||||
logger.debug("CallController: reporting remote ended")
|
logger.debug("CallController: reporting remote ended")
|
||||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||||
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||||
} else if invitation.contact.id == activeCallInvitation?.contact.id {
|
} else if invitation.contact.id == activeCallInvitation?.contact.id {
|
||||||
activeCallInvitation = nil
|
activeCallInvitation = nil
|
||||||
@@ -288,14 +334,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
|
|
||||||
func reportCallRemoteEnded(call: Call) {
|
func reportCallRemoteEnded(call: Call) {
|
||||||
logger.debug("CallController: reporting remote ended")
|
logger.debug("CallController: reporting remote ended")
|
||||||
if CallController.useCallKit(), let uuid = call.callkitUUID {
|
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||||
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startCall(_ contact: Contact, _ media: CallMediaType) {
|
func startCall(_ contact: Contact, _ media: CallMediaType) {
|
||||||
logger.debug("CallController.startCall")
|
logger.debug("CallController.startCall")
|
||||||
let uuid = callManager.newOutgoingCall(contact, media)
|
let callUUID = callManager.newOutgoingCall(contact, media)
|
||||||
|
guard let uuid = UUID(uuidString: callUUID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
if CallController.useCallKit() {
|
if CallController.useCallKit() {
|
||||||
let handle = CXHandle(type: .generic, value: contact.id)
|
let handle = CXHandle(type: .generic, value: contact.id)
|
||||||
let action = CXStartCallAction(call: uuid, handle: handle)
|
let action = CXStartCallAction(call: uuid, handle: handle)
|
||||||
@@ -307,19 +356,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
update.localizedCallerName = contact.displayName
|
update.localizedCallerName = contact.displayName
|
||||||
self.provider.reportCall(with: uuid, updated: update)
|
self.provider.reportCall(with: uuid, updated: update)
|
||||||
}
|
}
|
||||||
} else if callManager.startOutgoingCall(callUUID: uuid) {
|
} else if callManager.startOutgoingCall(callUUID: callUUID) {
|
||||||
if callManager.startOutgoingCall(callUUID: uuid) {
|
logger.debug("CallController.startCall: call started")
|
||||||
logger.debug("CallController.startCall: call started")
|
} else {
|
||||||
} else {
|
logger.error("CallController.startCall: no active call")
|
||||||
logger.error("CallController.startCall: no active call")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func answerCall(invitation: RcvCallInvitation) {
|
func answerCall(invitation: RcvCallInvitation) {
|
||||||
logger.debug("CallController: answering a call")
|
logger.debug("CallController: answering a call")
|
||||||
if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
|
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||||
requestTransaction(with: CXAnswerCallAction(call: callUUID))
|
requestTransaction(with: CXAnswerCallAction(call: uuid))
|
||||||
} else {
|
} else {
|
||||||
callManager.answerIncomingCall(invitation: invitation)
|
callManager.answerIncomingCall(invitation: invitation)
|
||||||
}
|
}
|
||||||
@@ -328,10 +375,13 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func endCall(callUUID: UUID) {
|
func endCall(callUUID: String) {
|
||||||
logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
|
let uuid = UUID(uuidString: callUUID)
|
||||||
|
logger.debug("CallController: ending the call with UUID \(callUUID)")
|
||||||
if CallController.useCallKit() {
|
if CallController.useCallKit() {
|
||||||
requestTransaction(with: CXEndCallAction(call: callUUID))
|
if let uuid {
|
||||||
|
requestTransaction(with: CXEndCallAction(call: uuid))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
callManager.endCall(callUUID: callUUID) { ok in
|
callManager.endCall(callUUID: callUUID) { ok in
|
||||||
if ok {
|
if ok {
|
||||||
|
|||||||
@@ -10,25 +10,25 @@ import Foundation
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
class CallManager {
|
class CallManager {
|
||||||
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
|
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String {
|
||||||
let uuid = UUID()
|
let uuid = UUID().uuidString.lowercased()
|
||||||
let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
|
let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, initialCallType: media)
|
||||||
call.speakerEnabled = media == .video
|
call.speakerEnabled = media == .video
|
||||||
ChatModel.shared.activeCall = call
|
ChatModel.shared.activeCall = call
|
||||||
return uuid
|
return uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
func startOutgoingCall(callUUID: UUID) -> Bool {
|
func startOutgoingCall(callUUID: String) -> Bool {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
if let call = m.activeCall, call.callkitUUID == callUUID {
|
if let call = m.activeCall, call.callUUID == callUUID {
|
||||||
m.showCallView = true
|
m.showCallView = true
|
||||||
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
|
Task { await m.callCommand.processCommand(.capabilities(media: call.initialCallType)) }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func answerIncomingCall(callUUID: UUID) -> Bool {
|
func answerIncomingCall(callUUID: String) -> Bool {
|
||||||
if let invitation = getCallInvitation(callUUID) {
|
if let invitation = getCallInvitation(callUUID) {
|
||||||
answerIncomingCall(invitation: invitation)
|
answerIncomingCall(invitation: invitation)
|
||||||
return true
|
return true
|
||||||
@@ -42,9 +42,9 @@ class CallManager {
|
|||||||
let call = Call(
|
let call = Call(
|
||||||
direction: .incoming,
|
direction: .incoming,
|
||||||
contact: invitation.contact,
|
contact: invitation.contact,
|
||||||
callkitUUID: invitation.callkitUUID,
|
callUUID: invitation.callUUID,
|
||||||
callState: .invitationAccepted,
|
callState: .invitationAccepted,
|
||||||
localMedia: invitation.callType.media,
|
initialCallType: invitation.callType.media,
|
||||||
sharedKey: invitation.sharedKey
|
sharedKey: invitation.sharedKey
|
||||||
)
|
)
|
||||||
call.speakerEnabled = invitation.callType.media == .video
|
call.speakerEnabled = invitation.callType.media == .video
|
||||||
@@ -68,17 +68,17 @@ class CallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
|
func enableMedia(source: CallMediaSource, enable: Bool, callUUID: String) -> Bool {
|
||||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
|
Task { await m.callCommand.processCommand(.media(source: source, enable: enable)) }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
|
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) {
|
||||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID {
|
||||||
endCall(call: call) { completed(true) }
|
endCall(call: call) { completed(true) }
|
||||||
} else if let invitation = getCallInvitation(callUUID) {
|
} else if let invitation = getCallInvitation(callUUID) {
|
||||||
endCall(invitation: invitation) { completed(true) }
|
endCall(invitation: invitation) { completed(true) }
|
||||||
@@ -126,8 +126,8 @@ class CallManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
|
private func getCallInvitation(_ callUUID: String) -> RcvCallInvitation? {
|
||||||
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
|
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) {
|
||||||
return invitation
|
return invitation
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -10,40 +10,49 @@ import AVKit
|
|||||||
|
|
||||||
struct CallViewRemote: UIViewRepresentable {
|
struct CallViewRemote: UIViewRepresentable {
|
||||||
var client: WebRTCClient
|
var client: WebRTCClient
|
||||||
var activeCall: Binding<WebRTCClient.Call?>
|
@ObservedObject var call: Call
|
||||||
@State var enablePip: (Bool) -> Void = {_ in }
|
@State var enablePip: (Bool) -> Void = {_ in }
|
||||||
@Binding var activeCallViewIsCollapsed: Bool
|
@Binding var activeCallViewIsCollapsed: Bool
|
||||||
|
@Binding var contentMode: UIView.ContentMode
|
||||||
@Binding var pipShown: Bool
|
@Binding var pipShown: Bool
|
||||||
|
|
||||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, activeCallViewIsCollapsed: Binding<Bool>, pipShown: Binding<Bool>) {
|
|
||||||
self.client = client
|
|
||||||
self.activeCall = activeCall
|
|
||||||
self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
|
|
||||||
self._pipShown = pipShown
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
func makeUIView(context: Context) -> UIView {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
if let call = activeCall.wrappedValue {
|
let remoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
|
||||||
let remoteRenderer = RTCMTLVideoView(frame: view.frame)
|
remoteCameraRenderer.videoContentMode = contentMode
|
||||||
remoteRenderer.videoContentMode = .scaleAspectFill
|
remoteCameraRenderer.tag = 0
|
||||||
client.addRemoteRenderer(call, remoteRenderer)
|
|
||||||
addSubviewAndResize(remoteRenderer, into: view)
|
|
||||||
|
|
||||||
if AVPictureInPictureController.isPictureInPictureSupported() {
|
let screenVideo = call.peerMediaSources.screenVideo
|
||||||
makeViewWithRTCRenderer(call, remoteRenderer, view, context)
|
let remoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
|
||||||
}
|
remoteScreenRenderer.videoContentMode = contentMode
|
||||||
|
remoteScreenRenderer.tag = 1
|
||||||
|
remoteScreenRenderer.alpha = screenVideo ? 1 : 0
|
||||||
|
|
||||||
|
context.coordinator.cameraRenderer = remoteCameraRenderer
|
||||||
|
context.coordinator.screenRenderer = remoteScreenRenderer
|
||||||
|
client.addRemoteCameraRenderer(remoteCameraRenderer)
|
||||||
|
client.addRemoteScreenRenderer(remoteScreenRenderer)
|
||||||
|
if screenVideo {
|
||||||
|
addSubviewAndResize(remoteScreenRenderer, remoteCameraRenderer, into: view)
|
||||||
|
} else {
|
||||||
|
addSubviewAndResize(remoteCameraRenderer, remoteScreenRenderer, into: view)
|
||||||
|
}
|
||||||
|
|
||||||
|
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||||
|
makeViewWithRTCRenderer(remoteCameraRenderer, remoteScreenRenderer, view, context)
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
|
func makeViewWithRTCRenderer(_ remoteCameraRenderer: RTCMTLVideoView, _ remoteScreenRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
|
||||||
let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
|
let pipRemoteCameraRenderer = RTCMTLVideoView(frame: view.frame)
|
||||||
pipRemoteRenderer.videoContentMode = .scaleAspectFill
|
pipRemoteCameraRenderer.videoContentMode = .scaleAspectFill
|
||||||
|
|
||||||
|
let pipRemoteScreenRenderer = RTCMTLVideoView(frame: view.frame)
|
||||||
|
pipRemoteScreenRenderer.videoContentMode = .scaleAspectFill
|
||||||
|
|
||||||
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
|
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
|
||||||
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
|
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
|
||||||
addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view)
|
|
||||||
let pipContentSource = AVPictureInPictureController.ContentSource(
|
let pipContentSource = AVPictureInPictureController.ContentSource(
|
||||||
activeVideoCallSourceView: view,
|
activeVideoCallSourceView: view,
|
||||||
contentViewController: pipVideoCallViewController
|
contentViewController: pipVideoCallViewController
|
||||||
@@ -55,7 +64,9 @@ struct CallViewRemote: UIViewRepresentable {
|
|||||||
context.coordinator.pipController = pipController
|
context.coordinator.pipController = pipController
|
||||||
context.coordinator.willShowHide = { show in
|
context.coordinator.willShowHide = { show in
|
||||||
if show {
|
if show {
|
||||||
client.addRemoteRenderer(call, pipRemoteRenderer)
|
client.addRemoteCameraRenderer(pipRemoteCameraRenderer)
|
||||||
|
client.addRemoteScreenRenderer(pipRemoteScreenRenderer)
|
||||||
|
context.coordinator.relayout()
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
activeCallViewIsCollapsed = true
|
activeCallViewIsCollapsed = true
|
||||||
}
|
}
|
||||||
@@ -67,13 +78,29 @@ struct CallViewRemote: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
context.coordinator.didShowHide = { show in
|
context.coordinator.didShowHide = { show in
|
||||||
if show {
|
if show {
|
||||||
remoteRenderer.isHidden = true
|
remoteCameraRenderer.isHidden = true
|
||||||
|
remoteScreenRenderer.isHidden = true
|
||||||
} else {
|
} else {
|
||||||
client.removeRemoteRenderer(call, pipRemoteRenderer)
|
client.removeRemoteCameraRenderer(pipRemoteCameraRenderer)
|
||||||
remoteRenderer.isHidden = false
|
client.removeRemoteScreenRenderer(pipRemoteScreenRenderer)
|
||||||
|
remoteCameraRenderer.isHidden = false
|
||||||
|
remoteScreenRenderer.isHidden = false
|
||||||
}
|
}
|
||||||
pipShown = show
|
pipShown = show
|
||||||
}
|
}
|
||||||
|
context.coordinator.relayout = {
|
||||||
|
let camera = call.peerMediaSources.camera
|
||||||
|
let screenVideo = call.peerMediaSources.screenVideo
|
||||||
|
pipRemoteCameraRenderer.alpha = camera ? 1 : 0
|
||||||
|
pipRemoteScreenRenderer.alpha = screenVideo ? 1 : 0
|
||||||
|
if screenVideo {
|
||||||
|
addSubviewAndResize(pipRemoteScreenRenderer, pipRemoteCameraRenderer, pip: true, into: pipVideoCallViewController.view)
|
||||||
|
} else {
|
||||||
|
addSubviewAndResize(pipRemoteCameraRenderer, pipRemoteScreenRenderer, pip: true, into: pipVideoCallViewController.view)
|
||||||
|
}
|
||||||
|
(pipVideoCallViewController.view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
|
||||||
|
(pipVideoCallViewController.view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
|
||||||
|
}
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
enablePip = { enable in
|
enablePip = { enable in
|
||||||
if enable != pipShown /* pipController.isPictureInPictureActive */ {
|
if enable != pipShown /* pipController.isPictureInPictureActive */ {
|
||||||
@@ -88,24 +115,50 @@ struct CallViewRemote: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator()
|
Coordinator(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ view: UIView, context: Context) {
|
func updateUIView(_ view: UIView, context: Context) {
|
||||||
logger.debug("CallView.updateUIView remote")
|
logger.debug("CallView.updateUIView remote")
|
||||||
|
let camera = view.subviews.first(where: { $0.tag == 0 })!
|
||||||
|
let screen = view.subviews.first(where: { $0.tag == 1 })!
|
||||||
|
let screenVideo = call.peerMediaSources.screenVideo
|
||||||
|
if screenVideo && screen.alpha == 0 {
|
||||||
|
screen.alpha = 1
|
||||||
|
addSubviewAndResize(screen, camera, into: view)
|
||||||
|
} else if !screenVideo && screen.alpha == 1 {
|
||||||
|
screen.alpha = 0
|
||||||
|
addSubviewAndResize(camera, screen, into: view)
|
||||||
|
}
|
||||||
|
(view.subviews[0] as! RTCMTLVideoView).videoContentMode = contentMode
|
||||||
|
(view.subviews[1] as! RTCMTLVideoView).videoContentMode = .scaleAspectFill
|
||||||
|
|
||||||
|
camera.alpha = call.peerMediaSources.camera ? 1 : 0
|
||||||
|
screen.alpha = call.peerMediaSources.screenVideo ? 1 : 0
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if activeCallViewIsCollapsed != pipShown {
|
if activeCallViewIsCollapsed != pipShown {
|
||||||
enablePip(activeCallViewIsCollapsed)
|
enablePip(activeCallViewIsCollapsed)
|
||||||
|
} else if pipShown {
|
||||||
|
context.coordinator.relayout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Coordinator
|
// MARK: - Coordinator
|
||||||
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
|
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
|
||||||
|
var cameraRenderer: RTCMTLVideoView?
|
||||||
|
var screenRenderer: RTCMTLVideoView?
|
||||||
|
var client: WebRTCClient
|
||||||
var pipController: AVPictureInPictureController? = nil
|
var pipController: AVPictureInPictureController? = nil
|
||||||
var willShowHide: (Bool) -> Void = { _ in }
|
var willShowHide: (Bool) -> Void = { _ in }
|
||||||
var didShowHide: (Bool) -> Void = { _ in }
|
var didShowHide: (Bool) -> Void = { _ in }
|
||||||
|
var relayout: () -> Void = {}
|
||||||
|
|
||||||
|
required init(_ client: WebRTCClient) {
|
||||||
|
self.client = client
|
||||||
|
}
|
||||||
|
|
||||||
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
willShowHide(true)
|
willShowHide(true)
|
||||||
}
|
}
|
||||||
@@ -127,11 +180,20 @@ struct CallViewRemote: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
// TODO: deinit is not called when changing call type from audio to video and back,
|
||||||
|
// which causes many renderers can be created and added to stream (if enabling/disabling
|
||||||
|
// video while not yet connected in outgoing call)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||||
pipController?.contentSource = nil
|
pipController?.contentSource = nil
|
||||||
pipController?.delegate = nil
|
pipController?.delegate = nil
|
||||||
pipController = nil
|
pipController = nil
|
||||||
|
if let cameraRenderer {
|
||||||
|
client.removeRemoteCameraRenderer(cameraRenderer)
|
||||||
|
}
|
||||||
|
if let screenRenderer {
|
||||||
|
client.removeRemoteScreenRenderer(screenRenderer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,51 +210,109 @@ struct CallViewRemote: UIViewRepresentable {
|
|||||||
|
|
||||||
struct CallViewLocal: UIViewRepresentable {
|
struct CallViewLocal: UIViewRepresentable {
|
||||||
var client: WebRTCClient
|
var client: WebRTCClient
|
||||||
var activeCall: Binding<WebRTCClient.Call?>
|
|
||||||
var localRendererAspectRatio: Binding<CGFloat?>
|
var localRendererAspectRatio: Binding<CGFloat?>
|
||||||
@State var pipStateChanged: (Bool) -> Void = {_ in }
|
@State var pipStateChanged: (Bool) -> Void = {_ in }
|
||||||
@Binding var pipShown: Bool
|
@Binding var pipShown: Bool
|
||||||
|
|
||||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
|
init(client: WebRTCClient, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
|
||||||
self.client = client
|
self.client = client
|
||||||
self.activeCall = activeCall
|
|
||||||
self.localRendererAspectRatio = localRendererAspectRatio
|
self.localRendererAspectRatio = localRendererAspectRatio
|
||||||
self._pipShown = pipShown
|
self._pipShown = pipShown
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
func makeUIView(context: Context) -> UIView {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
if let call = activeCall.wrappedValue {
|
let localRenderer = RTCEAGLVideoView(frame: .zero)
|
||||||
let localRenderer = RTCEAGLVideoView(frame: .zero)
|
context.coordinator.renderer = localRenderer
|
||||||
client.addLocalRenderer(call, localRenderer)
|
client.addLocalRenderer(localRenderer)
|
||||||
client.startCaptureLocalVideo(call)
|
addSubviewAndResize(localRenderer, nil, into: view)
|
||||||
addSubviewAndResize(localRenderer, into: view)
|
DispatchQueue.main.async {
|
||||||
DispatchQueue.main.async {
|
pipStateChanged = { shown in
|
||||||
pipStateChanged = { shown in
|
localRenderer.isHidden = shown
|
||||||
localRenderer.isHidden = shown
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(client)
|
||||||
|
}
|
||||||
|
|
||||||
func updateUIView(_ view: UIView, context: Context) {
|
func updateUIView(_ view: UIView, context: Context) {
|
||||||
logger.debug("CallView.updateUIView local")
|
logger.debug("CallView.updateUIView local")
|
||||||
pipStateChanged(pipShown)
|
pipStateChanged(pipShown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinator
|
||||||
|
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
|
||||||
|
var renderer: RTCEAGLVideoView?
|
||||||
|
var client: WebRTCClient
|
||||||
|
|
||||||
|
required init(_ client: WebRTCClient) {
|
||||||
|
self.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let renderer {
|
||||||
|
client.removeLocalRenderer(renderer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addSubviewAndResize(_ view: UIView, into containerView: UIView) {
|
private func addSubviewAndResize(_ fullscreen: UIView, _ end: UIView?, pip: Bool = false, into containerView: UIView) {
|
||||||
containerView.addSubview(view)
|
if containerView.subviews.firstIndex(of: fullscreen) == 0 && ((end == nil && containerView.subviews.count == 1) || (end != nil && containerView.subviews.firstIndex(of: end!) == 1)) {
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
// Nothing to do, elements on their places
|
||||||
containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|",
|
return
|
||||||
options: [],
|
}
|
||||||
metrics: nil,
|
containerView.removeConstraints(containerView.constraints)
|
||||||
views: ["view": view]))
|
containerView.subviews.forEach { sub in sub.removeFromSuperview()}
|
||||||
|
|
||||||
containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|",
|
containerView.addSubview(fullscreen)
|
||||||
|
fullscreen.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
fullscreen.layer.cornerRadius = 0
|
||||||
|
fullscreen.layer.masksToBounds = false
|
||||||
|
|
||||||
|
if let end {
|
||||||
|
containerView.addSubview(end)
|
||||||
|
end.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
end.layer.cornerRadius = pip ? 8 : 10
|
||||||
|
end.layer.masksToBounds = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let constraintFullscreenV = NSLayoutConstraint.constraints(
|
||||||
|
withVisualFormat: "V:|[fullscreen]|",
|
||||||
options: [],
|
options: [],
|
||||||
metrics: nil,
|
metrics: nil,
|
||||||
views: ["view": view]))
|
views: ["fullscreen": fullscreen]
|
||||||
|
)
|
||||||
|
let constraintFullscreenH = NSLayoutConstraint.constraints(
|
||||||
|
withVisualFormat: "H:|[fullscreen]|",
|
||||||
|
options: [],
|
||||||
|
metrics: nil,
|
||||||
|
views: ["fullscreen": fullscreen]
|
||||||
|
)
|
||||||
|
|
||||||
|
containerView.addConstraints(constraintFullscreenV)
|
||||||
|
containerView.addConstraints(constraintFullscreenH)
|
||||||
|
|
||||||
|
if let end {
|
||||||
|
let constraintEndWidth = NSLayoutConstraint(
|
||||||
|
item: end, attribute: .width, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 : 0.3, constant: 0
|
||||||
|
)
|
||||||
|
let constraintEndHeight = NSLayoutConstraint(
|
||||||
|
item: end, attribute: .height, relatedBy: .equal, toItem: containerView, attribute: .width, multiplier: pip ? 0.5 * 1.33 : 0.3 * 1.33, constant: 0
|
||||||
|
)
|
||||||
|
let constraintEndX = NSLayoutConstraint(
|
||||||
|
item: end, attribute: .leading, relatedBy: .equal, toItem: containerView, attribute: .trailing, multiplier: pip ? 0.5 : 0.7, constant: pip ? -8 : -17
|
||||||
|
)
|
||||||
|
let constraintEndY = NSLayoutConstraint(
|
||||||
|
item: end, attribute: .bottom, relatedBy: .equal, toItem: containerView, attribute: .bottom, multiplier: 1, constant: pip ? -8 : -92
|
||||||
|
)
|
||||||
|
containerView.addConstraint(constraintEndWidth)
|
||||||
|
containerView.addConstraint(constraintEndHeight)
|
||||||
|
containerView.addConstraint(constraintEndX)
|
||||||
|
containerView.addConstraint(constraintEndY)
|
||||||
|
}
|
||||||
containerView.layoutIfNeeded()
|
containerView.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct IncomingCallView: View {
|
struct IncomingCallView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var cc = CallController.shared
|
@ObservedObject var cc = CallController.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -37,13 +38,14 @@ struct IncomingCallView: View {
|
|||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
ProfilePreview(profileOf: invitation.contact, color: .white)
|
ProfilePreview(profileOf: invitation.contact, color: .white)
|
||||||
|
.padding(.vertical, 6)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
callButton("Reject", "phone.down.fill", .red) {
|
callButton("Reject", "phone.down.fill", .red) {
|
||||||
cc.endCall(invitation: invitation)
|
cc.endCall(invitation: invitation)
|
||||||
}
|
}
|
||||||
|
|
||||||
callButton("Ignore", "multiply", .accentColor) {
|
callButton("Ignore", "multiply", .primary) {
|
||||||
cc.activeCallInvitation = nil
|
cc.activeCallInvitation = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ struct IncomingCallView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.modifier(ThemedBackground())
|
||||||
.onAppear { dismissAllSheets() }
|
.onAppear { dismissAllSheets() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ struct IncomingCallView: View {
|
|||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 44)
|
.frame(minWidth: 44)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,49 +18,49 @@ class Call: ObservableObject, Equatable {
|
|||||||
|
|
||||||
var direction: CallDirection
|
var direction: CallDirection
|
||||||
var contact: Contact
|
var contact: Contact
|
||||||
var callkitUUID: UUID?
|
var callUUID: String?
|
||||||
var localMedia: CallMediaType
|
var initialCallType: CallMediaType
|
||||||
|
@Published var localMediaSources: CallMediaSources
|
||||||
@Published var callState: CallState
|
@Published var callState: CallState
|
||||||
@Published var localCapabilities: CallCapabilities?
|
@Published var localCapabilities: CallCapabilities?
|
||||||
@Published var peerMedia: CallMediaType?
|
@Published var peerMediaSources: CallMediaSources = CallMediaSources()
|
||||||
@Published var sharedKey: String?
|
@Published var sharedKey: String?
|
||||||
@Published var audioEnabled = true
|
|
||||||
@Published var speakerEnabled = false
|
@Published var speakerEnabled = false
|
||||||
@Published var videoEnabled: Bool
|
|
||||||
@Published var connectionInfo: ConnectionInfo?
|
@Published var connectionInfo: ConnectionInfo?
|
||||||
@Published var connectedAt: Date? = nil
|
@Published var connectedAt: Date? = nil
|
||||||
|
|
||||||
init(
|
init(
|
||||||
direction: CallDirection,
|
direction: CallDirection,
|
||||||
contact: Contact,
|
contact: Contact,
|
||||||
callkitUUID: UUID?,
|
callUUID: String?,
|
||||||
callState: CallState,
|
callState: CallState,
|
||||||
localMedia: CallMediaType,
|
initialCallType: CallMediaType,
|
||||||
sharedKey: String? = nil
|
sharedKey: String? = nil
|
||||||
) {
|
) {
|
||||||
self.direction = direction
|
self.direction = direction
|
||||||
self.contact = contact
|
self.contact = contact
|
||||||
self.callkitUUID = callkitUUID
|
self.callUUID = callUUID
|
||||||
self.callState = callState
|
self.callState = callState
|
||||||
self.localMedia = localMedia
|
self.initialCallType = initialCallType
|
||||||
self.sharedKey = sharedKey
|
self.sharedKey = sharedKey
|
||||||
self.videoEnabled = localMedia == .video
|
self.localMediaSources = CallMediaSources(
|
||||||
|
mic: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized,
|
||||||
|
camera: initialCallType == .video && AVCaptureDevice.authorizationStatus(for: .video) == .authorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
var encrypted: Bool { get { localEncrypted && sharedKey != nil } }
|
var encrypted: Bool { get { localEncrypted && sharedKey != nil } }
|
||||||
var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
|
private var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
|
||||||
var encryptionStatus: LocalizedStringKey {
|
var encryptionStatus: LocalizedStringKey {
|
||||||
get {
|
get {
|
||||||
switch callState {
|
switch callState {
|
||||||
case .waitCapabilities: return ""
|
case .waitCapabilities: return ""
|
||||||
case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption"
|
case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption"
|
||||||
case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
|
case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption"
|
||||||
default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
|
default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
|
var hasVideo: Bool { get { localMediaSources.hasVideo || peerMediaSources.hasVideo } }
|
||||||
var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CallDirection {
|
enum CallDirection {
|
||||||
@@ -105,18 +105,28 @@ struct WVAPIMessage: Equatable, Decodable, Encodable {
|
|||||||
var command: WCallCommand?
|
var command: WCallCommand?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CallMediaSources: Equatable, Codable {
|
||||||
|
var mic: Bool = false
|
||||||
|
var camera: Bool = false
|
||||||
|
var screenAudio: Bool = false
|
||||||
|
var screenVideo: Bool = false
|
||||||
|
|
||||||
|
var hasVideo: Bool { get { camera || screenVideo } }
|
||||||
|
}
|
||||||
|
|
||||||
enum WCallCommand: Equatable, Encodable, Decodable {
|
enum WCallCommand: Equatable, Encodable, Decodable {
|
||||||
case capabilities(media: CallMediaType)
|
case capabilities(media: CallMediaType)
|
||||||
case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
||||||
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil)
|
||||||
case answer(answer: String, iceCandidates: String)
|
case answer(answer: String, iceCandidates: String)
|
||||||
case ice(iceCandidates: String)
|
case ice(iceCandidates: String)
|
||||||
case media(media: CallMediaType, enable: Bool)
|
case media(source: CallMediaSource, enable: Bool)
|
||||||
case end
|
case end
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case type
|
case type
|
||||||
case media
|
case media
|
||||||
|
case source
|
||||||
case aesKey
|
case aesKey
|
||||||
case offer
|
case offer
|
||||||
case answer
|
case answer
|
||||||
@@ -167,9 +177,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
|||||||
case let .ice(iceCandidates):
|
case let .ice(iceCandidates):
|
||||||
try container.encode("ice", forKey: .type)
|
try container.encode("ice", forKey: .type)
|
||||||
try container.encode(iceCandidates, forKey: .iceCandidates)
|
try container.encode(iceCandidates, forKey: .iceCandidates)
|
||||||
case let .media(media, enable):
|
case let .media(source, enable):
|
||||||
try container.encode("media", forKey: .type)
|
try container.encode("media", forKey: .type)
|
||||||
try container.encode(media, forKey: .media)
|
try container.encode(source, forKey: .media)
|
||||||
try container.encode(enable, forKey: .enable)
|
try container.encode(enable, forKey: .enable)
|
||||||
case .end:
|
case .end:
|
||||||
try container.encode("end", forKey: .type)
|
try container.encode("end", forKey: .type)
|
||||||
@@ -205,9 +215,9 @@ enum WCallCommand: Equatable, Encodable, Decodable {
|
|||||||
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates)
|
||||||
self = .ice(iceCandidates: iceCandidates)
|
self = .ice(iceCandidates: iceCandidates)
|
||||||
case "media":
|
case "media":
|
||||||
let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
|
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
|
||||||
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
|
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
|
||||||
self = .media(media: media, enable: enable)
|
self = .media(source: source, enable: enable)
|
||||||
case "end":
|
case "end":
|
||||||
self = .end
|
self = .end
|
||||||
default:
|
default:
|
||||||
@@ -224,6 +234,7 @@ enum WCallResponse: Equatable, Decodable {
|
|||||||
case ice(iceCandidates: String)
|
case ice(iceCandidates: String)
|
||||||
case connection(state: ConnectionState)
|
case connection(state: ConnectionState)
|
||||||
case connected(connectionInfo: ConnectionInfo)
|
case connected(connectionInfo: ConnectionInfo)
|
||||||
|
case peerMedia(source: CallMediaSource, enabled: Bool)
|
||||||
case ended
|
case ended
|
||||||
case ok
|
case ok
|
||||||
case error(message: String)
|
case error(message: String)
|
||||||
@@ -238,6 +249,8 @@ enum WCallResponse: Equatable, Decodable {
|
|||||||
case state
|
case state
|
||||||
case connectionInfo
|
case connectionInfo
|
||||||
case message
|
case message
|
||||||
|
case source
|
||||||
|
case enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
var respType: String {
|
var respType: String {
|
||||||
@@ -249,6 +262,7 @@ enum WCallResponse: Equatable, Decodable {
|
|||||||
case .ice: return "ice"
|
case .ice: return "ice"
|
||||||
case .connection: return "connection"
|
case .connection: return "connection"
|
||||||
case .connected: return "connected"
|
case .connected: return "connected"
|
||||||
|
case .peerMedia: return "peerMedia"
|
||||||
case .ended: return "ended"
|
case .ended: return "ended"
|
||||||
case .ok: return "ok"
|
case .ok: return "ok"
|
||||||
case .error: return "error"
|
case .error: return "error"
|
||||||
@@ -283,6 +297,10 @@ enum WCallResponse: Equatable, Decodable {
|
|||||||
case "connected":
|
case "connected":
|
||||||
let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo)
|
let connectionInfo = try container.decode(ConnectionInfo.self, forKey: CodingKeys.connectionInfo)
|
||||||
self = .connected(connectionInfo: connectionInfo)
|
self = .connected(connectionInfo: connectionInfo)
|
||||||
|
case "peerMedia":
|
||||||
|
let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source)
|
||||||
|
let enabled = try container.decode(Bool.self, forKey: CodingKeys.enabled)
|
||||||
|
self = .peerMedia(source: source, enabled: enabled)
|
||||||
case "ended":
|
case "ended":
|
||||||
self = .ended
|
self = .ended
|
||||||
case "ok":
|
case "ok":
|
||||||
@@ -324,6 +342,10 @@ extension WCallResponse: Encodable {
|
|||||||
case let .connected(connectionInfo):
|
case let .connected(connectionInfo):
|
||||||
try container.encode("connected", forKey: .type)
|
try container.encode("connected", forKey: .type)
|
||||||
try container.encode(connectionInfo, forKey: .connectionInfo)
|
try container.encode(connectionInfo, forKey: .connectionInfo)
|
||||||
|
case let .peerMedia(source, enabled):
|
||||||
|
try container.encode("peerMedia", forKey: .type)
|
||||||
|
try container.encode(source, forKey: .source)
|
||||||
|
try container.encode(enabled, forKey: .enabled)
|
||||||
case .ended:
|
case .ended:
|
||||||
try container.encode("ended", forKey: .type)
|
try container.encode("ended", forKey: .type)
|
||||||
case .ok:
|
case .ok:
|
||||||
@@ -376,7 +398,7 @@ actor WebRTCCommandProcessor {
|
|||||||
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
|
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
|
||||||
switch c {
|
switch c {
|
||||||
case .capabilities, .start, .offer, .end: true
|
case .capabilities, .start, .offer, .end: true
|
||||||
default: client.activeCall.wrappedValue != nil
|
default: client.activeCall != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
struct Call {
|
struct Call {
|
||||||
var connection: RTCPeerConnection
|
var connection: RTCPeerConnection
|
||||||
var iceCandidates: IceCandidates
|
var iceCandidates: IceCandidates
|
||||||
var localMedia: CallMediaType
|
|
||||||
var localCamera: RTCVideoCapturer?
|
var localCamera: RTCVideoCapturer?
|
||||||
var localVideoSource: RTCVideoSource?
|
var localAudioTrack: RTCAudioTrack?
|
||||||
var localStream: RTCVideoTrack?
|
var localVideoTrack: RTCVideoTrack?
|
||||||
var remoteStream: RTCVideoTrack?
|
var remoteAudioTrack: RTCAudioTrack?
|
||||||
var device: AVCaptureDevice.Position = .front
|
var remoteVideoTrack: RTCVideoTrack?
|
||||||
|
var remoteScreenAudioTrack: RTCAudioTrack?
|
||||||
|
var remoteScreenVideoTrack: RTCVideoTrack?
|
||||||
|
var device: AVCaptureDevice.Position
|
||||||
var aesKey: String?
|
var aesKey: String?
|
||||||
var frameEncryptor: RTCFrameEncryptor?
|
var frameEncryptor: RTCFrameEncryptor?
|
||||||
var frameDecryptor: RTCFrameDecryptor?
|
var frameDecryptor: RTCFrameDecryptor?
|
||||||
|
var peerHasOldVersion: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NotConnectedCall {
|
||||||
|
var audioTrack: RTCAudioTrack?
|
||||||
|
var localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)?
|
||||||
|
var device: AVCaptureDevice.Position = .front
|
||||||
}
|
}
|
||||||
|
|
||||||
actor IceCandidates {
|
actor IceCandidates {
|
||||||
@@ -51,17 +60,20 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
private let rtcAudioSession = RTCAudioSession.sharedInstance()
|
private let rtcAudioSession = RTCAudioSession.sharedInstance()
|
||||||
private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio")
|
private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio")
|
||||||
private var sendCallResponse: (WVAPIMessage) async -> Void
|
private var sendCallResponse: (WVAPIMessage) async -> Void
|
||||||
var activeCall: Binding<Call?>
|
var activeCall: Call?
|
||||||
|
var notConnectedCall: NotConnectedCall?
|
||||||
private var localRendererAspectRatio: Binding<CGFloat?>
|
private var localRendererAspectRatio: Binding<CGFloat?>
|
||||||
|
|
||||||
|
var cameraRenderers: [RTCVideoRenderer] = []
|
||||||
|
var screenRenderers: [RTCVideoRenderer] = []
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
override init() {
|
override init() {
|
||||||
fatalError("Unimplemented")
|
fatalError("Unimplemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
required init(_ activeCall: Binding<Call?>, _ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding<CGFloat?>) {
|
required init(_ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding<CGFloat?>) {
|
||||||
self.sendCallResponse = sendCallResponse
|
self.sendCallResponse = sendCallResponse
|
||||||
self.activeCall = activeCall
|
|
||||||
self.localRendererAspectRatio = localRendererAspectRatio
|
self.localRendererAspectRatio = localRendererAspectRatio
|
||||||
rtcAudioSession.useManualAudio = CallController.useCallKit()
|
rtcAudioSession.useManualAudio = CallController.useCallKit()
|
||||||
rtcAudioSession.isAudioEnabled = !CallController.useCallKit()
|
rtcAudioSession.isAudioEnabled = !CallController.useCallKit()
|
||||||
@@ -78,39 +90,45 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
||||||
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
|
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
|
||||||
connection.delegate = self
|
connection.delegate = self
|
||||||
createAudioSender(connection)
|
let device = notConnectedCall?.device ?? .front
|
||||||
var localStream: RTCVideoTrack? = nil
|
|
||||||
var remoteStream: RTCVideoTrack? = nil
|
|
||||||
var localCamera: RTCVideoCapturer? = nil
|
var localCamera: RTCVideoCapturer? = nil
|
||||||
var localVideoSource: RTCVideoSource? = nil
|
var localAudioTrack: RTCAudioTrack? = nil
|
||||||
if mediaType == .video {
|
var localVideoTrack: RTCVideoTrack? = nil
|
||||||
(localStream, remoteStream, localCamera, localVideoSource) = createVideoSender(connection)
|
if let localCameraAndTrack = notConnectedCall?.localCameraAndTrack {
|
||||||
|
(localCamera, localVideoTrack) = localCameraAndTrack
|
||||||
|
} else if notConnectedCall == nil && mediaType == .video {
|
||||||
|
(localCamera, localVideoTrack) = createVideoTrackAndStartCapture(device)
|
||||||
}
|
}
|
||||||
|
if let audioTrack = notConnectedCall?.audioTrack {
|
||||||
|
localAudioTrack = audioTrack
|
||||||
|
} else if notConnectedCall == nil {
|
||||||
|
localAudioTrack = createAudioTrack()
|
||||||
|
}
|
||||||
|
notConnectedCall?.localCameraAndTrack = nil
|
||||||
|
notConnectedCall?.audioTrack = nil
|
||||||
|
|
||||||
var frameEncryptor: RTCFrameEncryptor? = nil
|
var frameEncryptor: RTCFrameEncryptor? = nil
|
||||||
var frameDecryptor: RTCFrameDecryptor? = nil
|
var frameDecryptor: RTCFrameDecryptor? = nil
|
||||||
if aesKey != nil {
|
if aesKey != nil {
|
||||||
let encryptor = RTCFrameEncryptor.init(sizeChange: Int32(WebRTCClient.ivTagBytes))
|
let encryptor = RTCFrameEncryptor.init(sizeChange: Int32(WebRTCClient.ivTagBytes))
|
||||||
encryptor.delegate = self
|
encryptor.delegate = self
|
||||||
frameEncryptor = encryptor
|
frameEncryptor = encryptor
|
||||||
connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
|
|
||||||
|
|
||||||
let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes))
|
let decryptor = RTCFrameDecryptor.init(sizeChange: -Int32(WebRTCClient.ivTagBytes))
|
||||||
decryptor.delegate = self
|
decryptor.delegate = self
|
||||||
frameDecryptor = decryptor
|
frameDecryptor = decryptor
|
||||||
// Has no video receiver in outgoing call if applied here, see [peerConnection(_ connection: RTCPeerConnection, didChange newState]
|
|
||||||
// connection.receivers.forEach { $0.setRtcFrameDecryptor(decryptor) }
|
|
||||||
}
|
}
|
||||||
return Call(
|
return Call(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
iceCandidates: IceCandidates(),
|
iceCandidates: IceCandidates(),
|
||||||
localMedia: mediaType,
|
|
||||||
localCamera: localCamera,
|
localCamera: localCamera,
|
||||||
localVideoSource: localVideoSource,
|
localAudioTrack: localAudioTrack,
|
||||||
localStream: localStream,
|
localVideoTrack: localVideoTrack,
|
||||||
remoteStream: remoteStream,
|
device: device,
|
||||||
aesKey: aesKey,
|
aesKey: aesKey,
|
||||||
frameEncryptor: frameEncryptor,
|
frameEncryptor: frameEncryptor,
|
||||||
frameDecryptor: frameDecryptor
|
frameDecryptor: frameDecryptor,
|
||||||
|
peerHasOldVersion: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,18 +169,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
|
|
||||||
func sendCallCommand(command: WCallCommand) async {
|
func sendCallCommand(command: WCallCommand) async {
|
||||||
var resp: WCallResponse? = nil
|
var resp: WCallResponse? = nil
|
||||||
let pc = activeCall.wrappedValue?.connection
|
let pc = activeCall?.connection
|
||||||
switch command {
|
switch command {
|
||||||
case .capabilities:
|
case let .capabilities(media): // outgoing
|
||||||
|
let localCameraAndTrack: (RTCVideoCapturer, RTCVideoTrack)? = media == .video
|
||||||
|
? createVideoTrackAndStartCapture(.front)
|
||||||
|
: nil
|
||||||
|
notConnectedCall = NotConnectedCall(audioTrack: createAudioTrack(), localCameraAndTrack: localCameraAndTrack, device: .front)
|
||||||
resp = .capabilities(capabilities: CallCapabilities(encryption: WebRTCClient.enableEncryption))
|
resp = .capabilities(capabilities: CallCapabilities(encryption: WebRTCClient.enableEncryption))
|
||||||
case let .start(media: media, aesKey, iceServers, relay):
|
case let .start(media: media, aesKey, iceServers, relay): // incoming
|
||||||
logger.debug("starting incoming call - create webrtc session")
|
logger.debug("starting incoming call - create webrtc session")
|
||||||
if activeCall.wrappedValue != nil { endCall() }
|
if activeCall != nil { endCall() }
|
||||||
let encryption = WebRTCClient.enableEncryption
|
let encryption = WebRTCClient.enableEncryption
|
||||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
|
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, encryption ? aesKey : nil, relay)
|
||||||
activeCall.wrappedValue = call
|
activeCall = call
|
||||||
|
setupLocalTracks(true, call)
|
||||||
let (offer, error) = await call.connection.offer()
|
let (offer, error) = await call.connection.offer()
|
||||||
if let offer = offer {
|
if let offer = offer {
|
||||||
|
setupEncryptionForLocalTracks(call)
|
||||||
resp = .offer(
|
resp = .offer(
|
||||||
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
|
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
|
||||||
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
|
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
|
||||||
@@ -172,18 +196,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
} else {
|
} else {
|
||||||
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
|
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
|
||||||
}
|
}
|
||||||
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
|
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay): // outgoing
|
||||||
if activeCall.wrappedValue != nil {
|
if activeCall != nil {
|
||||||
resp = .error(message: "accept: call already started")
|
resp = .error(message: "accept: call already started")
|
||||||
} else if !WebRTCClient.enableEncryption && aesKey != nil {
|
} else if !WebRTCClient.enableEncryption && aesKey != nil {
|
||||||
resp = .error(message: "accept: encryption is not supported")
|
resp = .error(message: "accept: encryption is not supported")
|
||||||
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
|
} else if let offer: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: offer)),
|
||||||
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
|
let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) {
|
||||||
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
||||||
activeCall.wrappedValue = call
|
activeCall = call
|
||||||
let pc = call.connection
|
let pc = call.connection
|
||||||
if let type = offer.type, let sdp = offer.sdp {
|
if let type = offer.type, let sdp = offer.sdp {
|
||||||
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
|
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
|
||||||
|
setupLocalTracks(false, call)
|
||||||
|
setupEncryptionForLocalTracks(call)
|
||||||
|
pc.transceivers.forEach { transceiver in
|
||||||
|
transceiver.setDirection(.sendRecv, error: nil)
|
||||||
|
}
|
||||||
|
await adaptToOldVersion(pc.transceivers.count <= 2)
|
||||||
let (answer, error) = await pc.answer()
|
let (answer, error) = await pc.answer()
|
||||||
if let answer = answer {
|
if let answer = answer {
|
||||||
self.addIceCandidates(pc, remoteIceCandidates)
|
self.addIceCandidates(pc, remoteIceCandidates)
|
||||||
@@ -200,7 +230,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .answer(answer, iceCandidates):
|
case let .answer(answer, iceCandidates): // incoming
|
||||||
if pc == nil {
|
if pc == nil {
|
||||||
resp = .error(message: "answer: call not started")
|
resp = .error(message: "answer: call not started")
|
||||||
} else if pc?.localDescription == nil {
|
} else if pc?.localDescription == nil {
|
||||||
@@ -212,6 +242,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
let type = answer.type, let sdp = answer.sdp,
|
let type = answer.type, let sdp = answer.sdp,
|
||||||
let pc = pc {
|
let pc = pc {
|
||||||
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
|
if (try? await pc.setRemoteDescription(RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp))) != nil {
|
||||||
|
var currentDirection: RTCRtpTransceiverDirection = .sendOnly
|
||||||
|
pc.transceivers[2].currentDirection(¤tDirection)
|
||||||
|
await adaptToOldVersion(currentDirection == .sendOnly)
|
||||||
addIceCandidates(pc, remoteIceCandidates)
|
addIceCandidates(pc, remoteIceCandidates)
|
||||||
resp = .ok
|
resp = .ok
|
||||||
} else {
|
} else {
|
||||||
@@ -226,13 +259,11 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
} else {
|
} else {
|
||||||
resp = .error(message: "ice: call not started")
|
resp = .error(message: "ice: call not started")
|
||||||
}
|
}
|
||||||
case let .media(media, enable):
|
case let .media(source, enable):
|
||||||
if activeCall.wrappedValue == nil {
|
if activeCall == nil {
|
||||||
resp = .error(message: "media: call not started")
|
resp = .error(message: "media: call not started")
|
||||||
} else if activeCall.wrappedValue?.localMedia == .audio && media == .video {
|
|
||||||
resp = .error(message: "media: no video")
|
|
||||||
} else {
|
} else {
|
||||||
enableMedia(media, enable)
|
await enableMedia(source, enable)
|
||||||
resp = .ok
|
resp = .ok
|
||||||
}
|
}
|
||||||
case .end:
|
case .end:
|
||||||
@@ -247,7 +278,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
|
|
||||||
func getInitialIceCandidates() async -> [RTCIceCandidate] {
|
func getInitialIceCandidates() async -> [RTCIceCandidate] {
|
||||||
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
|
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
|
||||||
let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
|
let candidates = await activeCall?.iceCandidates.getAndClear() ?? []
|
||||||
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
|
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
|
||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
@@ -255,7 +286,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
func waitForMoreIceCandidates() {
|
func waitForMoreIceCandidates() {
|
||||||
Task {
|
Task {
|
||||||
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
|
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
|
||||||
let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
|
let candidates = await self.activeCall?.iceCandidates.getAndClear() ?? []
|
||||||
if candidates.count > 0 {
|
if candidates.count > 0 {
|
||||||
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
|
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
|
||||||
await self.sendIceCandidates(candidates)
|
await self.sendIceCandidates(candidates)
|
||||||
@@ -272,25 +303,202 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func enableMedia(_ media: CallMediaType, _ enable: Bool) {
|
func setupMuteUnmuteListener(_ transceiver: RTCRtpTransceiver, _ track: RTCMediaStreamTrack) {
|
||||||
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
|
// logger.log("Setting up mute/unmute listener in the call without encryption for mid = \(transceiver.mid)")
|
||||||
media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
|
Task {
|
||||||
|
var lastBytesReceived: Int64 = 0
|
||||||
|
// muted initially
|
||||||
|
var mutedSeconds = 4
|
||||||
|
while let call = self.activeCall, transceiver.receiver.track?.readyState == .live {
|
||||||
|
let stats: RTCStatisticsReport = await call.connection.statistics(for: transceiver.receiver)
|
||||||
|
let stat = stats.statistics.values.first(where: { stat in stat.type == "inbound-rtp"})
|
||||||
|
if let stat {
|
||||||
|
//logger.debug("Stat \(stat.debugDescription)")
|
||||||
|
let bytes = stat.values["bytesReceived"] as! Int64
|
||||||
|
if bytes <= lastBytesReceived {
|
||||||
|
mutedSeconds += 1
|
||||||
|
if mutedSeconds == 3 {
|
||||||
|
await MainActor.run {
|
||||||
|
self.onMediaMuteUnmute(transceiver.mid, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if mutedSeconds >= 3 {
|
||||||
|
await MainActor.run {
|
||||||
|
self.onMediaMuteUnmute(transceiver.mid, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastBytesReceived = bytes
|
||||||
|
mutedSeconds = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 1000_000000)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addLocalRenderer(_ activeCall: Call, _ renderer: RTCEAGLVideoView) {
|
@MainActor
|
||||||
activeCall.localStream?.add(renderer)
|
func onMediaMuteUnmute(_ transceiverMid: String?, _ mute: Bool) {
|
||||||
|
guard let activeCall = ChatModel.shared.activeCall else { return }
|
||||||
|
let source = mediaSourceFromTransceiverMid(transceiverMid)
|
||||||
|
logger.log("Mute/unmute \(source.rawValue) track = \(mute) with mid = \(transceiverMid ?? "nil")")
|
||||||
|
if source == .mic && activeCall.peerMediaSources.mic == mute {
|
||||||
|
activeCall.peerMediaSources.mic = !mute
|
||||||
|
} else if (source == .camera && activeCall.peerMediaSources.camera == mute) {
|
||||||
|
activeCall.peerMediaSources.camera = !mute
|
||||||
|
} else if (source == .screenAudio && activeCall.peerMediaSources.screenAudio == mute) {
|
||||||
|
activeCall.peerMediaSources.screenAudio = !mute
|
||||||
|
} else if (source == .screenVideo && activeCall.peerMediaSources.screenVideo == mute) {
|
||||||
|
activeCall.peerMediaSources.screenVideo = !mute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func enableMedia(_ source: CallMediaSource, _ enable: Bool) {
|
||||||
|
logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)")
|
||||||
|
source == .camera ? setCameraEnabled(enable) : setAudioEnabled(enable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func adaptToOldVersion(_ peerHasOldVersion: Bool) {
|
||||||
|
activeCall?.peerHasOldVersion = peerHasOldVersion
|
||||||
|
if peerHasOldVersion {
|
||||||
|
logger.debug("The peer has an old version. Remote audio track is nil = \(self.activeCall?.remoteAudioTrack == nil), video = \(self.activeCall?.remoteVideoTrack == nil)")
|
||||||
|
onMediaMuteUnmute("0", false)
|
||||||
|
if activeCall?.remoteVideoTrack != nil {
|
||||||
|
onMediaMuteUnmute("1", false)
|
||||||
|
}
|
||||||
|
if ChatModel.shared.activeCall?.localMediaSources.camera == true && ChatModel.shared.activeCall?.peerMediaSources.camera == false {
|
||||||
|
logger.debug("Stopping video track for the old version")
|
||||||
|
activeCall?.connection.senders[1].track = nil
|
||||||
|
ChatModel.shared.activeCall?.localMediaSources.camera = false
|
||||||
|
(activeCall?.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
||||||
|
activeCall?.localCamera = nil
|
||||||
|
activeCall?.localVideoTrack = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalRenderer(_ renderer: RTCEAGLVideoView) {
|
||||||
|
if let activeCall {
|
||||||
|
if let track = activeCall.localVideoTrack {
|
||||||
|
track.add(renderer)
|
||||||
|
}
|
||||||
|
} else if let notConnectedCall {
|
||||||
|
if let track = notConnectedCall.localCameraAndTrack?.1 {
|
||||||
|
track.add(renderer)
|
||||||
|
}
|
||||||
|
}
|
||||||
// To get width and height of a frame, see videoView(videoView:, didChangeVideoSize)
|
// To get width and height of a frame, see videoView(videoView:, didChangeVideoSize)
|
||||||
renderer.delegate = self
|
renderer.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeLocalRenderer(_ renderer: RTCEAGLVideoView) {
|
||||||
|
if let activeCall {
|
||||||
|
if let track = activeCall.localVideoTrack {
|
||||||
|
track.remove(renderer)
|
||||||
|
}
|
||||||
|
} else if let notConnectedCall {
|
||||||
|
if let track = notConnectedCall.localCameraAndTrack?.1 {
|
||||||
|
track.remove(renderer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer.delegate = nil
|
||||||
|
}
|
||||||
|
|
||||||
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
|
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
|
||||||
guard size.height > 0 else { return }
|
guard size.height > 0 else { return }
|
||||||
localRendererAspectRatio.wrappedValue = size.width / size.height
|
localRendererAspectRatio.wrappedValue = size.width / size.height
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupLocalTracks(_ incomingCall: Bool, _ call: Call) {
|
||||||
|
let pc = call.connection
|
||||||
|
let transceivers = call.connection.transceivers
|
||||||
|
let audioTrack = call.localAudioTrack
|
||||||
|
let videoTrack = call.localVideoTrack
|
||||||
|
|
||||||
|
if incomingCall {
|
||||||
|
let micCameraInit = RTCRtpTransceiverInit()
|
||||||
|
// streamIds required for old versions which adds tracks from stream, not from track property
|
||||||
|
micCameraInit.streamIds = ["micCamera"]
|
||||||
|
|
||||||
|
let screenAudioVideoInit = RTCRtpTransceiverInit()
|
||||||
|
screenAudioVideoInit.streamIds = ["screenAudioVideo"]
|
||||||
|
|
||||||
|
// incoming call, no transceivers yet. But they should be added in order: mic, camera, screen audio, screen video
|
||||||
|
// mid = 0, mic
|
||||||
|
if let audioTrack {
|
||||||
|
pc.addTransceiver(with: audioTrack, init: micCameraInit)
|
||||||
|
} else {
|
||||||
|
pc.addTransceiver(of: .audio, init: micCameraInit)
|
||||||
|
}
|
||||||
|
// mid = 1, camera
|
||||||
|
if let videoTrack {
|
||||||
|
pc.addTransceiver(with: videoTrack, init: micCameraInit)
|
||||||
|
} else {
|
||||||
|
pc.addTransceiver(of: .video, init: micCameraInit)
|
||||||
|
}
|
||||||
|
// mid = 2, screenAudio
|
||||||
|
pc.addTransceiver(of: .audio, init: screenAudioVideoInit)
|
||||||
|
// mid = 3, screenVideo
|
||||||
|
pc.addTransceiver(of: .video, init: screenAudioVideoInit)
|
||||||
|
} else {
|
||||||
|
// new version
|
||||||
|
if transceivers.count > 2 {
|
||||||
|
// Outgoing call. All transceivers are ready. Don't addTrack() because it will create new transceivers, replace existing (nil) tracks
|
||||||
|
transceivers
|
||||||
|
.first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .mic })?
|
||||||
|
.sender.track = audioTrack
|
||||||
|
transceivers
|
||||||
|
.first(where: { elem in mediaSourceFromTransceiverMid(elem.mid) == .camera })?
|
||||||
|
.sender.track = videoTrack
|
||||||
|
} else {
|
||||||
|
// old version, only two transceivers
|
||||||
|
if let audioTrack {
|
||||||
|
pc.add(audioTrack, streamIds: ["micCamera"])
|
||||||
|
} else {
|
||||||
|
// it's important to have any track in order to be able to turn it on again (currently it's off)
|
||||||
|
let sender = pc.add(createAudioTrack(), streamIds: ["micCamera"])
|
||||||
|
sender?.track = nil
|
||||||
|
}
|
||||||
|
if let videoTrack {
|
||||||
|
pc.add(videoTrack, streamIds: ["micCamera"])
|
||||||
|
} else {
|
||||||
|
// it's important to have any track in order to be able to turn it on again (currently it's off)
|
||||||
|
let localVideoSource = WebRTCClient.factory.videoSource()
|
||||||
|
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
|
||||||
|
let sender = pc.add(localVideoTrack, streamIds: ["micCamera"])
|
||||||
|
sender?.track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaSourceFromTransceiverMid(_ mid: String?) -> CallMediaSource {
|
||||||
|
switch mid {
|
||||||
|
case "0":
|
||||||
|
return .mic
|
||||||
|
case "1":
|
||||||
|
return .camera
|
||||||
|
case "2":
|
||||||
|
return .screenAudio
|
||||||
|
case "3":
|
||||||
|
return .screenVideo
|
||||||
|
default:
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be called after local description set
|
||||||
|
func setupEncryptionForLocalTracks(_ call: Call) {
|
||||||
|
if let encryptor = call.frameEncryptor {
|
||||||
|
call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? {
|
func frameDecryptor(_ decryptor: RTCFrameDecryptor, mediaType: RTCRtpMediaType, withFrame encrypted: Data) -> Data? {
|
||||||
guard encrypted.count > 0 else { return nil }
|
guard encrypted.count > 0 else { return nil }
|
||||||
if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
|
if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8),
|
||||||
let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) {
|
let pointer: UnsafeMutableRawPointer = malloc(encrypted.count) {
|
||||||
memcpy(pointer, (encrypted as NSData).bytes, encrypted.count)
|
memcpy(pointer, (encrypted as NSData).bytes, encrypted.count)
|
||||||
let isKeyFrame = encrypted[0] & 1 == 0
|
let isKeyFrame = encrypted[0] & 1 == 0
|
||||||
@@ -304,7 +512,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
|
|
||||||
func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? {
|
func frameEncryptor(_ encryptor: RTCFrameEncryptor, mediaType: RTCRtpMediaType, withFrame unencrypted: Data) -> Data? {
|
||||||
guard unencrypted.count > 0 else { return nil }
|
guard unencrypted.count > 0 else { return nil }
|
||||||
if var key: [CChar] = activeCall.wrappedValue?.aesKey?.cString(using: .utf8),
|
if var key: [CChar] = activeCall?.aesKey?.cString(using: .utf8),
|
||||||
let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) {
|
let pointer: UnsafeMutableRawPointer = malloc(unencrypted.count + WebRTCClient.ivTagBytes) {
|
||||||
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
|
memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count)
|
||||||
let isKeyFrame = unencrypted[0] & 1 == 0
|
let isKeyFrame = unencrypted[0] & 1 == 0
|
||||||
@@ -327,18 +535,42 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
|
func addRemoteCameraRenderer(_ renderer: RTCVideoRenderer) {
|
||||||
activeCall.remoteStream?.add(renderer)
|
if activeCall?.remoteVideoTrack != nil {
|
||||||
|
activeCall?.remoteVideoTrack?.add(renderer)
|
||||||
|
} else {
|
||||||
|
cameraRenderers.append(renderer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
|
func removeRemoteCameraRenderer(_ renderer: RTCVideoRenderer) {
|
||||||
activeCall.remoteStream?.remove(renderer)
|
if activeCall?.remoteVideoTrack != nil {
|
||||||
|
activeCall?.remoteVideoTrack?.remove(renderer)
|
||||||
|
} else {
|
||||||
|
cameraRenderers.removeAll(where: { $0.isEqual(renderer) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startCaptureLocalVideo(_ activeCall: Call) {
|
func addRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
|
||||||
|
if activeCall?.remoteScreenVideoTrack != nil {
|
||||||
|
activeCall?.remoteScreenVideoTrack?.add(renderer)
|
||||||
|
} else {
|
||||||
|
screenRenderers.append(renderer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeRemoteScreenRenderer(_ renderer: RTCVideoRenderer) {
|
||||||
|
if activeCall?.remoteScreenVideoTrack != nil {
|
||||||
|
activeCall?.remoteScreenVideoTrack?.remove(renderer)
|
||||||
|
} else {
|
||||||
|
screenRenderers.removeAll(where: { $0.isEqual(renderer) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
guard
|
guard
|
||||||
let capturer = activeCall.localCamera as? RTCFileVideoCapturer
|
let capturer = (activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCFileVideoCapturer
|
||||||
else {
|
else {
|
||||||
logger.error("Unable to work with a file capturer")
|
logger.error("Unable to work with a file capturer")
|
||||||
return
|
return
|
||||||
@@ -348,10 +580,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
capturer.startCapturing(fromFileNamed: "sounds/video.mp4")
|
capturer.startCapturing(fromFileNamed: "sounds/video.mp4")
|
||||||
#else
|
#else
|
||||||
guard
|
guard
|
||||||
let capturer = activeCall.localCamera as? RTCCameraVideoCapturer,
|
let capturer = capturer as? RTCCameraVideoCapturer,
|
||||||
let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == activeCall.device })
|
let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == device })
|
||||||
else {
|
else {
|
||||||
logger.error("Unable to find a camera")
|
logger.error("Unable to find a camera or local track")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,19 +609,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createAudioSender(_ connection: RTCPeerConnection) {
|
|
||||||
let streamId = "stream"
|
|
||||||
let audioTrack = createAudioTrack()
|
|
||||||
connection.add(audioTrack, streamIds: [streamId])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createVideoSender(_ connection: RTCPeerConnection) -> (RTCVideoTrack?, RTCVideoTrack?, RTCVideoCapturer?, RTCVideoSource?) {
|
|
||||||
let streamId = "stream"
|
|
||||||
let (localVideoTrack, localCamera, localVideoSource) = createVideoTrack()
|
|
||||||
connection.add(localVideoTrack, streamIds: [streamId])
|
|
||||||
return (localVideoTrack, connection.transceivers.first { $0.mediaType == .video }?.receiver.track as? RTCVideoTrack, localCamera, localVideoSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createAudioTrack() -> RTCAudioTrack {
|
private func createAudioTrack() -> RTCAudioTrack {
|
||||||
let audioConstrains = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
|
let audioConstrains = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
|
||||||
let audioSource = WebRTCClient.factory.audioSource(with: audioConstrains)
|
let audioSource = WebRTCClient.factory.audioSource(with: audioConstrains)
|
||||||
@@ -397,7 +616,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
return audioTrack
|
return audioTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createVideoTrack() -> (RTCVideoTrack, RTCVideoCapturer, RTCVideoSource) {
|
private func createVideoTrackAndStartCapture(_ device: AVCaptureDevice.Position) -> (RTCVideoCapturer, RTCVideoTrack) {
|
||||||
let localVideoSource = WebRTCClient.factory.videoSource()
|
let localVideoSource = WebRTCClient.factory.videoSource()
|
||||||
|
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
@@ -407,19 +626,30 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
|
let localVideoTrack = WebRTCClient.factory.videoTrack(with: localVideoSource, trackId: "video0")
|
||||||
return (localVideoTrack, localCamera, localVideoSource)
|
startCaptureLocalVideo(device, localCamera)
|
||||||
|
return (localCamera, localVideoTrack)
|
||||||
}
|
}
|
||||||
|
|
||||||
func endCall() {
|
func endCall() {
|
||||||
guard let call = activeCall.wrappedValue else { return }
|
if #available(iOS 16.0, *) {
|
||||||
|
_endCall()
|
||||||
|
} else {
|
||||||
|
// Fixes `connection.close()` getting locked up in iOS15
|
||||||
|
DispatchQueue.global(qos: .utility).async { self._endCall() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _endCall() {
|
||||||
|
(notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
|
||||||
|
guard let call = activeCall else { return }
|
||||||
logger.debug("WebRTCClient: ending the call")
|
logger.debug("WebRTCClient: ending the call")
|
||||||
activeCall.wrappedValue = nil
|
|
||||||
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
|
||||||
call.connection.close()
|
call.connection.close()
|
||||||
call.connection.delegate = nil
|
call.connection.delegate = nil
|
||||||
call.frameEncryptor?.delegate = nil
|
call.frameEncryptor?.delegate = nil
|
||||||
call.frameDecryptor?.delegate = nil
|
call.frameDecryptor?.delegate = nil
|
||||||
|
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
||||||
audioSessionToDefaults()
|
audioSessionToDefaults()
|
||||||
|
activeCall = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
|
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
|
||||||
@@ -428,7 +658,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
|
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
|
||||||
t += stepMs
|
t += stepMs
|
||||||
await action()
|
await action()
|
||||||
} while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
|
} while t < timeoutMs && activeCall?.connection.iceGatheringState != .complete
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,11 +719,40 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
|||||||
logger.debug("Connection should negotiate")
|
logger.debug("Connection should negotiate")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) {
|
||||||
|
if let track = transceiver.receiver.track {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// Doesn't work for outgoing video call (audio in video call works ok still, same as incoming call)
|
||||||
|
// if let decryptor = self.activeCall?.frameDecryptor {
|
||||||
|
// transceiver.receiver.setRtcFrameDecryptor(decryptor)
|
||||||
|
// }
|
||||||
|
let source = self.mediaSourceFromTransceiverMid(transceiver.mid)
|
||||||
|
switch source {
|
||||||
|
case .mic: self.activeCall?.remoteAudioTrack = track as? RTCAudioTrack
|
||||||
|
case .camera:
|
||||||
|
self.activeCall?.remoteVideoTrack = track as? RTCVideoTrack
|
||||||
|
self.cameraRenderers.forEach({ renderer in
|
||||||
|
self.activeCall?.remoteVideoTrack?.add(renderer)
|
||||||
|
})
|
||||||
|
self.cameraRenderers.removeAll()
|
||||||
|
case .screenAudio: self.activeCall?.remoteScreenAudioTrack = track as? RTCAudioTrack
|
||||||
|
case .screenVideo:
|
||||||
|
self.activeCall?.remoteScreenVideoTrack = track as? RTCVideoTrack
|
||||||
|
self.screenRenderers.forEach({ renderer in
|
||||||
|
self.activeCall?.remoteScreenVideoTrack?.add(renderer)
|
||||||
|
})
|
||||||
|
self.screenRenderers.removeAll()
|
||||||
|
case .unknown: ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.setupMuteUnmuteListener(transceiver, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
|
func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
|
||||||
debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)")
|
debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)")
|
||||||
|
|
||||||
guard let call = activeCall.wrappedValue,
|
guard let connectionStateString = newState.toString(),
|
||||||
let connectionStateString = newState.toString(),
|
|
||||||
let iceConnectionStateString = connection.iceConnectionState.toString(),
|
let iceConnectionStateString = connection.iceConnectionState.toString(),
|
||||||
let iceGatheringStateString = connection.iceGatheringState.toString(),
|
let iceGatheringStateString = connection.iceGatheringState.toString(),
|
||||||
let signalingStateString = connection.signalingState.toString()
|
let signalingStateString = connection.signalingState.toString()
|
||||||
@@ -514,18 +773,14 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
|||||||
|
|
||||||
switch newState {
|
switch newState {
|
||||||
case .checking:
|
case .checking:
|
||||||
if let frameDecryptor = activeCall.wrappedValue?.frameDecryptor {
|
if let frameDecryptor = activeCall?.frameDecryptor {
|
||||||
connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) }
|
connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) }
|
||||||
}
|
}
|
||||||
let enableSpeaker: Bool
|
let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true
|
||||||
switch call.localMedia {
|
|
||||||
case .video: enableSpeaker = true
|
|
||||||
default: enableSpeaker = false
|
|
||||||
}
|
|
||||||
setSpeakerEnabledAndConfigureSession(enableSpeaker)
|
setSpeakerEnabledAndConfigureSession(enableSpeaker)
|
||||||
case .connected: sendConnectedEvent(connection)
|
case .connected: sendConnectedEvent(connection)
|
||||||
case .disconnected, .failed: endCall()
|
case .disconnected, .failed: endCall()
|
||||||
default: do {}
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,7 +792,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
|||||||
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
|
func peerConnection(_ connection: RTCPeerConnection, didGenerate candidate: WebRTC.RTCIceCandidate) {
|
||||||
// logger.debug("Connection generated candidate \(candidate.debugDescription)")
|
// logger.debug("Connection generated candidate \(candidate.debugDescription)")
|
||||||
Task {
|
Task {
|
||||||
await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
|
await self.activeCall?.iceCandidates.append(candidate.toCandidate(nil, nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,11 +847,42 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension WebRTCClient {
|
extension WebRTCClient {
|
||||||
func setAudioEnabled(_ enabled: Bool) {
|
static func isAuthorized(for type: AVMediaType) async -> Bool {
|
||||||
setTrackEnabled(RTCAudioTrack.self, enabled)
|
let status = AVCaptureDevice.authorizationStatus(for: type)
|
||||||
|
var isAuthorized = status == .authorized
|
||||||
|
if status == .notDetermined {
|
||||||
|
isAuthorized = await AVCaptureDevice.requestAccess(for: type)
|
||||||
|
}
|
||||||
|
return isAuthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSpeakerEnabledAndConfigureSession( _ enabled: Bool) {
|
static func showUnauthorizedAlert(for type: AVMediaType) {
|
||||||
|
if type == .audio {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("No permission to record speech"),
|
||||||
|
message: Text("To record speech please grant permission to use Microphone."),
|
||||||
|
primaryButton: .default(Text("Open Settings")) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
))
|
||||||
|
} else if type == .video {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("No permission to record video"),
|
||||||
|
message: Text("To record video please grant permission to use Camera."),
|
||||||
|
primaryButton: .default(Text("Open Settings")) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSpeakerEnabledAndConfigureSession( _ enabled: Bool, skipExternalDevice: Bool = false) {
|
||||||
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)")
|
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)")
|
||||||
audioQueue.async { [weak self] in
|
audioQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -609,7 +895,7 @@ extension WebRTCClient {
|
|||||||
if enabled {
|
if enabled {
|
||||||
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||||
try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue)
|
try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue)
|
||||||
if hasExternalAudioDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
|
if hasExternalAudioDevice && !skipExternalDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
|
||||||
try self.rtcAudioSession.setPreferredInput(preferred)
|
try self.rtcAudioSession.setPreferredInput(preferred)
|
||||||
} else {
|
} else {
|
||||||
try self.rtcAudioSession.overrideOutputAudioPort(.speaker)
|
try self.rtcAudioSession.overrideOutputAudioPort(.speaker)
|
||||||
@@ -619,7 +905,7 @@ extension WebRTCClient {
|
|||||||
try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
|
try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
|
||||||
try self.rtcAudioSession.overrideOutputAudioPort(.none)
|
try self.rtcAudioSession.overrideOutputAudioPort(.none)
|
||||||
}
|
}
|
||||||
if hasExternalAudioDevice {
|
if hasExternalAudioDevice && !skipExternalDevice {
|
||||||
logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker")
|
logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker")
|
||||||
}
|
}
|
||||||
try self.rtcAudioSession.setActive(true)
|
try self.rtcAudioSession.setActive(true)
|
||||||
@@ -650,25 +936,59 @@ extension WebRTCClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setVideoEnabled(_ enabled: Bool) {
|
@MainActor
|
||||||
setTrackEnabled(RTCVideoTrack.self, enabled)
|
func setAudioEnabled(_ enabled: Bool) {
|
||||||
|
if activeCall != nil {
|
||||||
|
activeCall?.localAudioTrack = enabled ? createAudioTrack() : nil
|
||||||
|
activeCall?.connection.transceivers.first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .mic })?.sender.track = activeCall?.localAudioTrack
|
||||||
|
} else if notConnectedCall != nil {
|
||||||
|
notConnectedCall?.audioTrack = enabled ? createAudioTrack() : nil
|
||||||
|
}
|
||||||
|
ChatModel.shared.activeCall?.localMediaSources.mic = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func setCameraEnabled(_ enabled: Bool) {
|
||||||
|
if let call = activeCall {
|
||||||
|
if enabled {
|
||||||
|
if call.localVideoTrack == nil {
|
||||||
|
let device = activeCall?.device ?? notConnectedCall?.device ?? .front
|
||||||
|
let (camera, track) = createVideoTrackAndStartCapture(device)
|
||||||
|
activeCall?.localCamera = camera
|
||||||
|
activeCall?.localVideoTrack = track
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(call.localCamera as? RTCCameraVideoCapturer)?.stopCapture()
|
||||||
|
activeCall?.localCamera = nil
|
||||||
|
activeCall?.localVideoTrack = nil
|
||||||
|
}
|
||||||
|
call.connection.transceivers
|
||||||
|
.first(where: { t in mediaSourceFromTransceiverMid(t.mid) == .camera })?
|
||||||
|
.sender.track = activeCall?.localVideoTrack
|
||||||
|
ChatModel.shared.activeCall?.localMediaSources.camera = activeCall?.localVideoTrack != nil
|
||||||
|
} else if let call = notConnectedCall {
|
||||||
|
if enabled {
|
||||||
|
let device = activeCall?.device ?? notConnectedCall?.device ?? .front
|
||||||
|
notConnectedCall?.localCameraAndTrack = createVideoTrackAndStartCapture(device)
|
||||||
|
} else {
|
||||||
|
(call.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture()
|
||||||
|
notConnectedCall?.localCameraAndTrack = nil
|
||||||
|
}
|
||||||
|
ChatModel.shared.activeCall?.localMediaSources.camera = notConnectedCall?.localCameraAndTrack != nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func flipCamera() {
|
func flipCamera() {
|
||||||
switch activeCall.wrappedValue?.device {
|
let device = activeCall?.device ?? notConnectedCall?.device
|
||||||
case .front: activeCall.wrappedValue?.device = .back
|
if activeCall != nil {
|
||||||
case .back: activeCall.wrappedValue?.device = .front
|
activeCall?.device = device == .front ? .back : .front
|
||||||
default: ()
|
} else {
|
||||||
|
notConnectedCall?.device = device == .front ? .back : .front
|
||||||
}
|
}
|
||||||
if let call = activeCall.wrappedValue {
|
startCaptureLocalVideo(
|
||||||
startCaptureLocalVideo(call)
|
activeCall?.device ?? notConnectedCall?.device,
|
||||||
}
|
(activeCall?.localCamera ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCCameraVideoCapturer
|
||||||
}
|
)
|
||||||
|
|
||||||
private func setTrackEnabled<T: RTCMediaStreamTrack>(_ type: T.Type, _ enabled: Bool) {
|
|
||||||
activeCall.wrappedValue?.connection.transceivers
|
|
||||||
.compactMap { $0.sender.track as? T }
|
|
||||||
.forEach { $0.isEnabled = enabled }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
|
|
||||||
let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2)
|
|
||||||
struct ChatInfoToolbar: View {
|
struct ChatInfoToolbar: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var imageSize: CGFloat = 32
|
var imageSize: CGFloat = 32
|
||||||
|
|
||||||
@@ -26,29 +25,29 @@ struct ChatInfoToolbar: View {
|
|||||||
ChatInfoImage(
|
ChatInfoImage(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
size: imageSize,
|
size: imageSize,
|
||||||
color: colorScheme == .dark
|
color: Color(uiColor: .tertiaryLabel)
|
||||||
? chatImageColorDark
|
|
||||||
: chatImageColorLight
|
|
||||||
)
|
)
|
||||||
.padding(.trailing, 4)
|
.padding(.trailing, 4)
|
||||||
VStack {
|
let t = Text(cInfo.displayName).font(.headline)
|
||||||
let t = Text(cInfo.displayName).font(.headline)
|
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
.lineLimit(1)
|
||||||
.lineLimit(1)
|
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
|
||||||
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
|
VStack(spacing: 0) {
|
||||||
Text(cInfo.fullName).font(.subheadline)
|
v
|
||||||
.lineLimit(1)
|
Text(cInfo.fullName).font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.top, -2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.frame(width: 220)
|
.frame(width: 220)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var contactVerifiedShield: Text {
|
private var contactVerifiedShield: Text {
|
||||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.baselineOffset(1)
|
.baselineOffset(1)
|
||||||
.kerning(-2)
|
.kerning(-2)
|
||||||
}
|
}
|
||||||
@@ -57,5 +56,6 @@ struct ChatInfoToolbar: View {
|
|||||||
struct ChatInfoToolbar_Previews: PreviewProvider {
|
struct ChatInfoToolbar_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
||||||
|
.environmentObject(CurrentColors.toAppTheme())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
class AnimatedImageView: UIView {
|
class AnimatedImageView: UIView {
|
||||||
var image: UIImage? = nil
|
var image: UIImage? = nil
|
||||||
var imageView: UIImageView? = nil
|
var imageView: UIImageView? = nil
|
||||||
|
var cMode: UIView.ContentMode = .scaleAspectFit
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@@ -18,11 +19,12 @@ class AnimatedImageView: UIView {
|
|||||||
fatalError("Not implemented")
|
fatalError("Not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(image: UIImage) {
|
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
|
||||||
self.init()
|
self.init()
|
||||||
self.image = image
|
self.image = image
|
||||||
|
self.cMode = contentMode
|
||||||
imageView = UIImageView(gifImage: image)
|
imageView = UIImageView(gifImage: image)
|
||||||
imageView!.contentMode = .scaleAspectFit
|
imageView!.contentMode = contentMode
|
||||||
self.addSubview(imageView!)
|
self.addSubview(imageView!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ class AnimatedImageView: UIView {
|
|||||||
if let subview = self.subviews.first as? UIImageView {
|
if let subview = self.subviews.first as? UIImageView {
|
||||||
if image.imageData != subview.gifImage?.imageData {
|
if image.imageData != subview.gifImage?.imageData {
|
||||||
imageView = UIImageView(gifImage: image)
|
imageView = UIImageView(gifImage: image)
|
||||||
imageView!.contentMode = .scaleAspectFit
|
imageView!.contentMode = contentMode
|
||||||
self.addSubview(imageView!)
|
self.addSubview(imageView!)
|
||||||
subview.removeFromSuperview()
|
subview.removeFromSuperview()
|
||||||
}
|
}
|
||||||
@@ -47,13 +49,15 @@ class AnimatedImageView: UIView {
|
|||||||
|
|
||||||
struct SwiftyGif: UIViewRepresentable {
|
struct SwiftyGif: UIViewRepresentable {
|
||||||
private let image: UIImage
|
private let image: UIImage
|
||||||
|
private let contentMode: UIView.ContentMode
|
||||||
|
|
||||||
init(image: UIImage) {
|
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
|
||||||
self.image = image
|
self.image = image
|
||||||
|
self.contentMode = contentMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> AnimatedImageView {
|
func makeUIView(context: Context) -> AnimatedImageView {
|
||||||
AnimatedImageView(image: image)
|
AnimatedImageView(image: image, contentMode: contentMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CICallItemView: View {
|
struct CICallItemView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var status: CICallStatus
|
var status: CICallStatus
|
||||||
@@ -22,7 +23,7 @@ struct CICallItemView: View {
|
|||||||
switch status {
|
switch status {
|
||||||
case .pending:
|
case .pending:
|
||||||
if sent {
|
if sent {
|
||||||
Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
|
Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
acceptCallButton()
|
acceptCallButton()
|
||||||
}
|
}
|
||||||
@@ -35,7 +36,7 @@ struct CICallItemView: View {
|
|||||||
case .error: missedCallIcon(sent).foregroundColor(.orange)
|
case .error: missedCallIcon(sent).foregroundColor(.orange)
|
||||||
}
|
}
|
||||||
|
|
||||||
CIMetaView(chat: chat, chatItem: chatItem, showStatus: false, showEdited: false)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ struct CICallItemView: View {
|
|||||||
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "phone.down")
|
Image(systemName: "phone.down")
|
||||||
Text(durationText(duration)).foregroundColor(.secondary)
|
Text(durationText(duration)).foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ struct CICallItemView: View {
|
|||||||
Label("Answer call", systemImage: "phone.arrow.down.left")
|
Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIChatFeatureView: View {
|
struct CIChatFeatureView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@Environment(\.revealed) var revealed: Bool
|
||||||
|
@ObservedObject var im = ItemsModel.shared
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@Binding var revealed: Bool
|
|
||||||
var feature: Feature
|
var feature: Feature
|
||||||
var icon: String? = nil
|
var icon: String? = nil
|
||||||
var iconColor: Color
|
var iconColor: Color
|
||||||
@@ -52,8 +54,8 @@ struct CIChatFeatureView: View {
|
|||||||
var fs: [FeatureInfo] = []
|
var fs: [FeatureInfo] = []
|
||||||
var icons: Set<String> = []
|
var icons: Set<String> = []
|
||||||
if var i = m.getChatItemIndex(chatItem) {
|
if var i = m.getChatItemIndex(chatItem) {
|
||||||
while i < m.reversedChatItems.count,
|
while i < im.reversedChatItems.count,
|
||||||
let f = featureInfo(m.reversedChatItems[i]) {
|
let f = featureInfo(im.reversedChatItems[i]) {
|
||||||
if !icons.contains(f.icon) {
|
if !icons.contains(f.icon) {
|
||||||
fs.insert(f, at: 0)
|
fs.insert(f, at: 0)
|
||||||
icons.insert(f.icon)
|
icons.insert(f.icon)
|
||||||
@@ -66,10 +68,10 @@ struct CIChatFeatureView: View {
|
|||||||
|
|
||||||
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
||||||
switch ci.content {
|
switch ci.content {
|
||||||
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
|
||||||
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
|
||||||
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param)
|
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
|
||||||
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param)
|
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
|
||||||
default: nil
|
default: nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +83,7 @@ struct CIChatFeatureView: View {
|
|||||||
if let param = f.param {
|
if let param = f.param {
|
||||||
HStack {
|
HStack {
|
||||||
i
|
i
|
||||||
chatEventText(Text(param)).lineLimit(1)
|
chatEventText(Text(param), theme.colors.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
i
|
i
|
||||||
@@ -93,7 +95,7 @@ struct CIChatFeatureView: View {
|
|||||||
Image(systemName: icon ?? feature.iconFilled)
|
Image(systemName: icon ?? feature.iconFilled)
|
||||||
.foregroundColor(iconColor)
|
.foregroundColor(iconColor)
|
||||||
.scaleEffect(feature.iconScale)
|
.scaleEffect(feature.iconScale)
|
||||||
chatEventText(chatItem)
|
chatEventText(chatItem, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -104,6 +106,9 @@ struct CIChatFeatureView: View {
|
|||||||
struct CIChatFeatureView_Previews: PreviewProvider {
|
struct CIChatFeatureView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
||||||
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
|
CIChatFeatureView(
|
||||||
|
chat: Chat.sampleData,
|
||||||
|
chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
|
||||||
|
).environment(\.revealed, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIFeaturePreferenceView: View {
|
struct CIFeaturePreferenceView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var feature: ChatFeature
|
var feature: ChatFeature
|
||||||
var allowed: FeatureAllowed
|
var allowed: FeatureAllowed
|
||||||
@@ -19,7 +20,7 @@ struct CIFeaturePreferenceView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
Image(systemName: feature.icon)
|
Image(systemName: feature.icon)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.scaleEffect(feature.iconScale)
|
.scaleEffect(feature.iconScale)
|
||||||
if let ct = chat.chatInfo.contact,
|
if let ct = chat.chatInfo.contact,
|
||||||
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
||||||
@@ -40,17 +41,17 @@ struct CIFeaturePreferenceView: View {
|
|||||||
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
||||||
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
if let acceptText {
|
if let acceptText {
|
||||||
r = r
|
r = r
|
||||||
+ Text(acceptText)
|
+ Text(acceptText)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
}
|
}
|
||||||
r = r + chatItem.timestampText
|
r = r + chatItem.timestampText
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
return r.font(.caption)
|
return r.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,42 +11,48 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIFileView: View {
|
struct CIFileView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let file: CIFile?
|
let file: CIFile?
|
||||||
let edited: Bool
|
let edited: Bool
|
||||||
|
var smallViewSize: CGFloat?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let metaReserve = edited
|
if smallViewSize != nil {
|
||||||
? " "
|
fileIndicator()
|
||||||
: " "
|
.onTapGesture(perform: fileAction)
|
||||||
Button(action: fileAction) {
|
} else {
|
||||||
HStack(alignment: .bottom, spacing: 6) {
|
let metaReserve = edited
|
||||||
fileIndicator()
|
? " "
|
||||||
.padding(.top, 5)
|
: " "
|
||||||
.padding(.bottom, 3)
|
Button(action: fileAction) {
|
||||||
if let file = file {
|
HStack(alignment: .bottom, spacing: 6) {
|
||||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
fileIndicator()
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.padding(.top, 5)
|
||||||
Text(file.fileName)
|
.padding(.bottom, 3)
|
||||||
.lineLimit(1)
|
if let file = file {
|
||||||
.multilineTextAlignment(.leading)
|
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||||
.foregroundColor(.primary)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(prettyFileSize + metaReserve)
|
Text(file.fileName)
|
||||||
.font(.caption)
|
.lineLimit(1)
|
||||||
.lineLimit(1)
|
.multilineTextAlignment(.leading)
|
||||||
.multilineTextAlignment(.leading)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.foregroundColor(.secondary)
|
Text(prettyFileSize + metaReserve)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(metaReserve)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Text(metaReserve)
|
|
||||||
}
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
.padding(.leading, 10)
|
||||||
|
.padding(.trailing, 12)
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.disabled(!itemInteractive)
|
||||||
.padding(.bottom, 6)
|
|
||||||
.padding(.leading, 10)
|
|
||||||
.padding(.trailing, 12)
|
|
||||||
}
|
}
|
||||||
.disabled(!itemInteractive)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var itemInteractive: Bool {
|
private var itemInteractive: Bool {
|
||||||
@@ -54,16 +60,18 @@ struct CIFileView: View {
|
|||||||
switch (file.fileStatus) {
|
switch (file.fileStatus) {
|
||||||
case .sndStored: return file.fileProtocol == .local
|
case .sndStored: return file.fileProtocol == .local
|
||||||
case .sndTransfer: return false
|
case .sndTransfer: return false
|
||||||
case .sndComplete: return false
|
case .sndComplete: return true
|
||||||
case .sndCancelled: return false
|
case .sndCancelled: return false
|
||||||
case .sndError: return false
|
case .sndError: return true
|
||||||
|
case .sndWarning: return true
|
||||||
case .rcvInvitation: return true
|
case .rcvInvitation: return true
|
||||||
case .rcvAccepted: return true
|
case .rcvAccepted: return true
|
||||||
case .rcvTransfer: return false
|
case .rcvTransfer: return false
|
||||||
case .rcvAborted: return true
|
case .rcvAborted: return true
|
||||||
case .rcvComplete: return true
|
case .rcvComplete: return true
|
||||||
case .rcvCancelled: return false
|
case .rcvCancelled: return false
|
||||||
case .rcvError: return false
|
case .rcvError: return true
|
||||||
|
case .rcvWarning: return true
|
||||||
case .invalid: return false
|
case .invalid: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,11 +116,40 @@ struct CIFileView: View {
|
|||||||
if let fileSource = getLoadedFileSource(file) {
|
if let fileSource = getLoadedFileSource(file) {
|
||||||
saveCryptoFile(fileSource)
|
saveCryptoFile(fileSource)
|
||||||
}
|
}
|
||||||
|
case let .rcvError(rcvFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .rcvError")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .rcvWarning")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
case .sndStored:
|
case .sndStored:
|
||||||
logger.debug("CIFileView fileAction - in .sndStored")
|
logger.debug("CIFileView fileAction - in .sndStored")
|
||||||
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
|
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
|
||||||
saveCryptoFile(fileSource)
|
saveCryptoFile(fileSource)
|
||||||
}
|
}
|
||||||
|
case .sndComplete:
|
||||||
|
logger.debug("CIFileView fileAction - in .sndComplete")
|
||||||
|
if let fileSource = getLoadedFileSource(file) {
|
||||||
|
saveCryptoFile(fileSource)
|
||||||
|
}
|
||||||
|
case let .sndError(sndFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .sndError")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .sndWarning")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,9 +173,10 @@ struct CIFileView: View {
|
|||||||
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
||||||
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
|
case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
|
||||||
case .rcvInvitation:
|
case .rcvInvitation:
|
||||||
if fileSizeValid(file) {
|
if fileSizeValid(file) {
|
||||||
fileIcon("arrow.down.doc.fill", color: .accentColor)
|
fileIcon("arrow.down.doc.fill", color: theme.colors.primary)
|
||||||
} else {
|
} else {
|
||||||
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
||||||
}
|
}
|
||||||
@@ -150,10 +188,11 @@ struct CIFileView: View {
|
|||||||
progressView()
|
progressView()
|
||||||
}
|
}
|
||||||
case .rcvAborted:
|
case .rcvAborted:
|
||||||
fileIcon("doc.fill", color: .accentColor, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
|
fileIcon("doc.fill", color: theme.colors.primary, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
|
||||||
case .rcvComplete: fileIcon("doc.fill")
|
case .rcvComplete: fileIcon("doc.fill")
|
||||||
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
|
case .rcvWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
|
||||||
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -162,21 +201,22 @@ struct CIFileView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
||||||
ZStack(alignment: .center) {
|
let size = smallViewSize ?? 30
|
||||||
|
return ZStack(alignment: .center) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
if let innerIcon = innerIcon,
|
if let innerIcon = innerIcon,
|
||||||
let innerIconSize = innerIconSize {
|
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
|
||||||
Image(systemName: innerIcon)
|
Image(systemName: innerIcon)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(maxHeight: 16)
|
.frame(maxHeight: 16)
|
||||||
.frame(width: innerIconSize, height: innerIconSize)
|
.frame(width: innerIconSize, height: innerIconSize)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.top, 12)
|
.padding(.top, size / 2.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,17 +285,18 @@ struct CIFileView_Previews: PreviewProvider {
|
|||||||
file: nil
|
file: nil
|
||||||
)
|
)
|
||||||
Group {
|
Group {
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: sentFile)
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample())
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile)
|
||||||
}
|
}
|
||||||
|
.environment(\.revealed, false)
|
||||||
.previewLayout(.fixed(width: 360, height: 360))
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIGroupInvitationView: View {
|
struct CIGroupInvitationView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var groupInvitation: CIGroupInvitation
|
var groupInvitation: CIGroupInvitation
|
||||||
@@ -42,10 +43,10 @@ struct CIGroupInvitationView: View {
|
|||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
(
|
(
|
||||||
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
||||||
.foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
|
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
)
|
)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ struct CIGroupInvitationView: View {
|
|||||||
(
|
(
|
||||||
groupInvitationText()
|
groupInvitationText()
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
)
|
)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
}
|
}
|
||||||
@@ -65,12 +66,11 @@ struct CIGroupInvitationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CIMetaView(chat: chat, chatItem: chatItem, showStatus: false, showEdited: false)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||||
.onChange(of: inProgress) { inProgress in
|
.onChange(of: inProgress) { inProgress in
|
||||||
@@ -99,7 +99,7 @@ struct CIGroupInvitationView: View {
|
|||||||
private func groupInfoView(_ action: Bool) -> some View {
|
private func groupInfoView(_ action: Bool) -> some View {
|
||||||
var color: Color
|
var color: Color
|
||||||
if action && !inProgress {
|
if action && !inProgress {
|
||||||
color = chatIncognito ? .indigo : .accentColor
|
color = chatIncognito ? .indigo : theme.colors.primary
|
||||||
} else {
|
} else {
|
||||||
color = Color(uiColor: .tertiaryLabel)
|
color = Color(uiColor: .tertiaryLabel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,29 +11,37 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIImageView: View {
|
struct CIImageView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
let chatItem: ChatItem
|
let chatItem: ChatItem
|
||||||
let image: String
|
var preview: UIImage?
|
||||||
let maxWidth: CGFloat
|
let maxWidth: CGFloat
|
||||||
@Binding var imgWidth: CGFloat?
|
var imgWidth: CGFloat?
|
||||||
@State var scrollProxy: ScrollViewProxy?
|
var smallView: Bool = false
|
||||||
@State private var showFullScreenImage = false
|
@Binding var showFullScreenImage: Bool
|
||||||
|
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let file = chatItem.file
|
let file = chatItem.file
|
||||||
VStack(alignment: .center, spacing: 6) {
|
VStack(alignment: .center, spacing: 6) {
|
||||||
if let uiImage = getLoadedImage(file) {
|
if let uiImage = getLoadedImage(file) {
|
||||||
imageView(uiImage)
|
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
|
||||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
||||||
|
}
|
||||||
|
.if(!smallView) { view in
|
||||||
|
view.modifier(PrivacyBlur(blurred: $blurred))
|
||||||
}
|
}
|
||||||
.onTapGesture { showFullScreenImage = true }
|
.onTapGesture { showFullScreenImage = true }
|
||||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
showFullScreenImage = false
|
showFullScreenImage = false
|
||||||
}
|
}
|
||||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
} else if let preview {
|
||||||
let uiImage = UIImage(data: data) {
|
Group {
|
||||||
imageView(uiImage)
|
if smallView {
|
||||||
|
smallViewImageView(preview)
|
||||||
|
} else {
|
||||||
|
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
|
||||||
|
}
|
||||||
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let file = file {
|
if let file = file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
@@ -60,17 +68,39 @@ struct CIImageView: View {
|
|||||||
case .rcvTransfer: () // ?
|
case .rcvTransfer: () // ?
|
||||||
case .rcvComplete: () // ?
|
case .rcvComplete: () // ?
|
||||||
case .rcvCancelled: () // TODO
|
case .rcvCancelled: () // TODO
|
||||||
|
case let .rcvError(rcvFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndError(sndFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
showFullScreenImage = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func imageView(_ img: UIImage) -> some View {
|
private func imageView(_ img: UIImage) -> some View {
|
||||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { imgWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
if img.imageData == nil {
|
if img.imageData == nil {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
@@ -82,7 +112,26 @@ struct CIImageView: View {
|
|||||||
.frame(width: w, height: w * img.size.height / img.size.width)
|
.frame(width: w, height: w * img.size.height / img.size.width)
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
}
|
}
|
||||||
loadingIndicator()
|
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
|
||||||
|
loadingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smallViewImageView(_ img: UIImage) -> some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
if img.imageData == nil {
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: maxWidth, height: maxWidth)
|
||||||
|
} else {
|
||||||
|
SwiftyGif(image: img, contentMode: .scaleAspectFill)
|
||||||
|
.frame(width: maxWidth, height: maxWidth)
|
||||||
|
}
|
||||||
|
if chatItem.file?.showStatusIconInSmallView == true {
|
||||||
|
loadingIndicator()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +148,16 @@ struct CIImageView: View {
|
|||||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .sndError: fileIcon("xmark", 10, 13)
|
case .sndError: fileIcon("xmark", 10, 13)
|
||||||
|
case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||||
case .rcvTransfer: progressView()
|
case .rcvTransfer: progressView()
|
||||||
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||||
|
case .rcvComplete: EmptyView()
|
||||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .rcvError: fileIcon("xmark", 10, 13)
|
case .rcvError: fileIcon("xmark", 10, 13)
|
||||||
|
case .rcvWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
case .invalid: fileIcon("questionmark", 10, 13)
|
case .invalid: fileIcon("questionmark", 10, 13)
|
||||||
default: EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,9 +165,9 @@ struct CIImageView: View {
|
|||||||
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
|
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.resizable()
|
.resizable()
|
||||||
|
.invertedForegroundStyle()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,4 +178,12 @@ struct CIImageView: View {
|
|||||||
.tint(.white)
|
.tint(.white)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
|
||||||
|
switch fileStatus {
|
||||||
|
case .rcvInvitation: true
|
||||||
|
case .rcvAborted: true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CIInvalidJSONView: View {
|
struct CIInvalidJSONView: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var json: String
|
var json: String
|
||||||
@State private var showJSON = false
|
@State private var showJSON = false
|
||||||
|
|
||||||
@@ -21,10 +22,9 @@ struct CIInvalidJSONView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onTapGesture { showJSON = true }
|
.onTapGesture { showJSON = true }
|
||||||
.sheet(isPresented: $showJSON) {
|
.appSheet(isPresented: $showJSON) {
|
||||||
invalidJSONView(json)
|
invalidJSONView(json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,7 @@ func invalidJSONView(_ json: String) -> some View {
|
|||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CIInvalidJSONView_Previews: PreviewProvider {
|
struct CIInvalidJSONView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -10,16 +10,17 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct CILinkView: View {
|
struct CILinkView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let linkPreview: LinkPreview
|
let linkPreview: LinkPreview
|
||||||
|
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .center, spacing: 6) {
|
VStack(alignment: .center, spacing: 6) {
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
|
if let uiImage = imageFromBase64(linkPreview.image) {
|
||||||
let uiImage = UIImage(data: data) {
|
|
||||||
Image(uiImage: uiImage)
|
Image(uiImage: uiImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
|
.modifier(PrivacyBlur(blurred: $blurred))
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(linkPreview.title)
|
Text(linkPreview.title)
|
||||||
@@ -32,7 +33,7 @@ struct CILinkView: View {
|
|||||||
Text(linkPreview.uri.absoluteString)
|
Text(linkPreview.uri.absoluteString)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIMemberCreatedContactView: View {
|
struct CIMemberCreatedContactView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -43,12 +44,12 @@ struct CIMemberCreatedContactView: View {
|
|||||||
r = r
|
r = r
|
||||||
+ Text(openText)
|
+ Text(openText)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
}
|
}
|
||||||
r = r + chatItem.timestampText
|
r = r + chatItem.timestampText
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
return r.font(.caption)
|
return r.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +57,11 @@ struct CIMemberCreatedContactView: View {
|
|||||||
if let member = chatItem.memberDisplayName {
|
if let member = chatItem.memberDisplayName {
|
||||||
return Text(member + " " + chatItem.content.text + " ")
|
return Text(member + " " + chatItem.content.text + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
return Text(chatItem.content.text + " ")
|
return Text(chatItem.content.text + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIMetaView: View {
|
struct CIMetaView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var metaColor = Color.secondary
|
var metaColor: Color
|
||||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||||
var showStatus = true
|
var showStatus = true
|
||||||
var showEdited = true
|
var showEdited = true
|
||||||
|
var invertedMaterial = false
|
||||||
|
|
||||||
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||||
|
|
||||||
@@ -23,104 +26,156 @@ struct CIMetaView: View {
|
|||||||
if chatItem.isDeletedContent {
|
if chatItem.isDeletedContent {
|
||||||
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
|
chatItem.timestampText.font(.caption).foregroundColor(metaColor)
|
||||||
} else {
|
} else {
|
||||||
let meta = chatItem.meta
|
ZStack {
|
||||||
let ttl = chat.chatInfo.timedMessagesTTL
|
ciMetaText(
|
||||||
let encrypted = chatItem.encryptedFile
|
chatItem.meta,
|
||||||
switch meta.itemStatus {
|
chatTTL: chat.chatInfo.timedMessagesTTL,
|
||||||
case let .sndSent(sndProgress):
|
encrypted: chatItem.encryptedFile,
|
||||||
switch sndProgress {
|
color: metaColor,
|
||||||
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
paleColor: paleMetaColor,
|
||||||
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
colorMode: invertedMaterial
|
||||||
|
? .invertedMaterial
|
||||||
|
: .normal,
|
||||||
|
showStatus: showStatus,
|
||||||
|
showEdited: showEdited,
|
||||||
|
showViaProxy: showSentViaProxy,
|
||||||
|
showTimesamp: showTimestamp
|
||||||
|
).invertedForegroundStyle(enabled: invertedMaterial)
|
||||||
|
if invertedMaterial {
|
||||||
|
ciMetaText(
|
||||||
|
chatItem.meta,
|
||||||
|
chatTTL: chat.chatInfo.timedMessagesTTL,
|
||||||
|
encrypted: chatItem.encryptedFile,
|
||||||
|
colorMode: .normal,
|
||||||
|
onlyOverrides: true,
|
||||||
|
showStatus: showStatus,
|
||||||
|
showEdited: showEdited,
|
||||||
|
showViaProxy: showSentViaProxy,
|
||||||
|
showTimesamp: showTimestamp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
case let .sndRcvd(_, sndProgress):
|
|
||||||
switch sndProgress {
|
|
||||||
case .complete:
|
|
||||||
ZStack {
|
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
|
||||||
}
|
|
||||||
case .partial:
|
|
||||||
ZStack {
|
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SentCheckmark {
|
enum MetaColorMode {
|
||||||
case sent
|
// Renders provided colours
|
||||||
case rcvd1
|
case normal
|
||||||
case rcvd2
|
// Fully transparent meta - used for reserving space
|
||||||
|
case transparent
|
||||||
|
// Renders white on dark backgrounds and black on light ones
|
||||||
|
case invertedMaterial
|
||||||
|
|
||||||
|
func resolve(_ c: Color?) -> Color? {
|
||||||
|
switch self {
|
||||||
|
case .normal: c
|
||||||
|
case .transparent: .clear
|
||||||
|
case .invertedMaterial: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusSpacer(_ sent: Bool) -> Text {
|
||||||
|
switch self {
|
||||||
|
case .normal, .transparent:
|
||||||
|
Text(
|
||||||
|
sent
|
||||||
|
? Image("checkmark.wide")
|
||||||
|
: Image(systemName: "circlebadge.fill")
|
||||||
|
).foregroundColor(.clear)
|
||||||
|
case .invertedMaterial: Text(" ").kerning(13)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ciMetaText(
|
func ciMetaText(
|
||||||
_ meta: CIMeta,
|
_ meta: CIMeta,
|
||||||
chatTTL: Int?,
|
chatTTL: Int?,
|
||||||
encrypted: Bool?,
|
encrypted: Bool?,
|
||||||
color: Color = .clear,
|
color: Color = .clear, // we use this function to reserve space without rendering meta
|
||||||
transparent: Bool = false,
|
paleColor: Color? = nil,
|
||||||
sent: SentCheckmark? = nil,
|
primaryColor: Color = .accentColor,
|
||||||
|
colorMode: MetaColorMode = .normal,
|
||||||
|
onlyOverrides: Bool = false, // only render colors that differ from base
|
||||||
showStatus: Bool = true,
|
showStatus: Bool = true,
|
||||||
showEdited: Bool = true,
|
showEdited: Bool = true,
|
||||||
showViaProxy: Bool
|
showViaProxy: Bool,
|
||||||
|
showTimesamp: Bool
|
||||||
) -> Text {
|
) -> Text {
|
||||||
var r = Text("")
|
var r = Text("")
|
||||||
|
var space: Text? = nil
|
||||||
|
let appendSpace = {
|
||||||
|
if let sp = space {
|
||||||
|
r = r + sp
|
||||||
|
space = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resolved = colorMode.resolve(color)
|
||||||
if showEdited, meta.itemEdited {
|
if showEdited, meta.itemEdited {
|
||||||
r = r + statusIconText("pencil", color)
|
r = r + statusIconText("pencil", resolved)
|
||||||
}
|
}
|
||||||
if meta.disappearing {
|
if meta.disappearing {
|
||||||
r = r + statusIconText("timer", color).font(.caption2)
|
r = r + statusIconText("timer", resolved).font(.caption2)
|
||||||
let ttl = meta.itemTimed?.ttl
|
let ttl = meta.itemTimed?.ttl
|
||||||
if ttl != chatTTL {
|
if ttl != chatTTL {
|
||||||
r = r + Text(shortTimeText(ttl)).foregroundColor(color)
|
r = r + colored(Text(shortTimeText(ttl)), resolved)
|
||||||
}
|
}
|
||||||
r = r + Text(" ")
|
space = Text(" ")
|
||||||
}
|
}
|
||||||
if showViaProxy, meta.sentViaProxy == true {
|
if showViaProxy, meta.sentViaProxy == true {
|
||||||
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
|
appendSpace()
|
||||||
|
r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2)
|
||||||
}
|
}
|
||||||
if showStatus {
|
if showStatus {
|
||||||
if let (icon, statusColor) = meta.statusIcon(color) {
|
appendSpace()
|
||||||
let t = Text(Image(systemName: icon)).font(.caption2)
|
if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) {
|
||||||
let gap = Text(" ").kerning(-1.25)
|
let metaColor = if onlyOverrides && statusColor == color {
|
||||||
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
Color.clear
|
||||||
switch sent {
|
} else {
|
||||||
case nil: r = r + t1
|
colorMode.resolve(statusColor)
|
||||||
case .sent: r = r + t1 + gap
|
|
||||||
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
|
|
||||||
case .rcvd2: r = r + gap + t1
|
|
||||||
}
|
}
|
||||||
r = r + Text(" ")
|
r = r + colored(Text(image), metaColor)
|
||||||
} else if !meta.disappearing {
|
} else if !meta.disappearing {
|
||||||
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
|
r = r + colorMode.statusSpacer(meta.itemStatus.sent)
|
||||||
}
|
}
|
||||||
|
space = Text(" ")
|
||||||
}
|
}
|
||||||
if let enc = encrypted {
|
if let enc = encrypted {
|
||||||
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
|
appendSpace()
|
||||||
|
r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
|
||||||
|
space = Text(" ")
|
||||||
|
}
|
||||||
|
if showTimesamp {
|
||||||
|
appendSpace()
|
||||||
|
r = r + colored(meta.timestampText, resolved)
|
||||||
}
|
}
|
||||||
r = r + meta.timestampText.foregroundColor(color)
|
|
||||||
return r.font(.caption)
|
return r.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
private func statusIconText(_ icon: String, _ color: Color?) -> Text {
|
||||||
Text(Image(systemName: icon)).foregroundColor(color)
|
colored(Text(Image(systemName: icon)), color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
|
||||||
|
private func colored(_ t: Text, _ color: Color?) -> Text {
|
||||||
|
if let color {
|
||||||
|
t.foregroundColor(color)
|
||||||
|
} else {
|
||||||
|
t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CIMetaView_Previews: PreviewProvider {
|
struct CIMetaView_Previews: PreviewProvider {
|
||||||
|
static let metaColor = Color.secondary
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor)
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 100))
|
.previewLayout(.fixed(width: 360, height: 100))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
|
|||||||
|
|
||||||
struct CIRcvDecryptionError: View {
|
struct CIRcvDecryptionError: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||||
var msgDecryptError: MsgDecryptError
|
var msgDecryptError: MsgDecryptError
|
||||||
var msgCount: UInt32
|
var msgCount: UInt32
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@@ -26,7 +28,7 @@ struct CIRcvDecryptionError: View {
|
|||||||
case syncNotSupportedContactAlert
|
case syncNotSupportedContactAlert
|
||||||
case syncNotSupportedMemberAlert
|
case syncNotSupportedMemberAlert
|
||||||
case decryptionErrorAlert
|
case decryptionErrorAlert
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -46,7 +48,7 @@ struct CIRcvDecryptionError: View {
|
|||||||
if case let .group(groupInfo) = chat.chatInfo,
|
if case let .group(groupInfo) = chat.chatInfo,
|
||||||
case let .groupRcv(groupMember) = chatItem.chatDir {
|
case let .groupRcv(groupMember) = chatItem.chatDir {
|
||||||
do {
|
do {
|
||||||
let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
|
let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId)
|
||||||
if let s = stats {
|
if let s = stats {
|
||||||
m.updateGroupMemberConnectionStats(groupInfo, member, s)
|
m.updateGroupMemberConnectionStats(groupInfo, member, s)
|
||||||
}
|
}
|
||||||
@@ -61,43 +63,46 @@ struct CIRcvDecryptionError: View {
|
|||||||
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
|
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
|
||||||
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
|
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
|
||||||
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func viewBody() -> some View {
|
@ViewBuilder private func viewBody() -> some View {
|
||||||
if case let .direct(contact) = chat.chatInfo,
|
Group {
|
||||||
let contactStats = contact.activeConn?.connectionStats {
|
if case let .direct(contact) = chat.chatInfo,
|
||||||
if contactStats.ratchetSyncAllowed {
|
let contactStats = contact.activeConn?.connectionStats {
|
||||||
decryptionErrorItemFixButton(syncSupported: true) {
|
if contactStats.ratchetSyncAllowed {
|
||||||
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
decryptionErrorItemFixButton(syncSupported: true) {
|
||||||
|
alert = .syncAllowedAlert { syncContactConnection(contact) }
|
||||||
|
}
|
||||||
|
} else if !contactStats.ratchetSyncSupported {
|
||||||
|
decryptionErrorItemFixButton(syncSupported: false) {
|
||||||
|
alert = .syncNotSupportedContactAlert
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
basicDecryptionErrorItem()
|
||||||
}
|
}
|
||||||
} else if !contactStats.ratchetSyncSupported {
|
} else if case let .group(groupInfo) = chat.chatInfo,
|
||||||
decryptionErrorItemFixButton(syncSupported: false) {
|
case let .groupRcv(groupMember) = chatItem.chatDir,
|
||||||
alert = .syncNotSupportedContactAlert
|
let mem = m.getGroupMember(groupMember.groupMemberId),
|
||||||
|
let memberStats = mem.wrapped.activeConn?.connectionStats {
|
||||||
|
if memberStats.ratchetSyncAllowed {
|
||||||
|
decryptionErrorItemFixButton(syncSupported: true) {
|
||||||
|
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
|
||||||
|
}
|
||||||
|
} else if !memberStats.ratchetSyncSupported {
|
||||||
|
decryptionErrorItemFixButton(syncSupported: false) {
|
||||||
|
alert = .syncNotSupportedMemberAlert
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
basicDecryptionErrorItem()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
basicDecryptionErrorItem()
|
basicDecryptionErrorItem()
|
||||||
}
|
}
|
||||||
} else if case let .group(groupInfo) = chat.chatInfo,
|
|
||||||
case let .groupRcv(groupMember) = chatItem.chatDir,
|
|
||||||
let mem = m.getGroupMember(groupMember.groupMemberId),
|
|
||||||
let memberStats = mem.wrapped.activeConn?.connectionStats {
|
|
||||||
if memberStats.ratchetSyncAllowed {
|
|
||||||
decryptionErrorItemFixButton(syncSupported: true) {
|
|
||||||
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
|
|
||||||
}
|
|
||||||
} else if !memberStats.ratchetSyncSupported {
|
|
||||||
decryptionErrorItemFixButton(syncSupported: false) {
|
|
||||||
alert = .syncNotSupportedMemberAlert
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
basicDecryptionErrorItem()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
basicDecryptionErrorItem()
|
|
||||||
}
|
}
|
||||||
|
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func basicDecryptionErrorItem() -> some View {
|
private func basicDecryptionErrorItem() -> some View {
|
||||||
@@ -114,24 +119,22 @@ struct CIRcvDecryptionError: View {
|
|||||||
}
|
}
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
||||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ Text("Fix connection")
|
+ Text("Fix connection")
|
||||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.onTapGesture(perform: { onClick() })
|
.onTapGesture(perform: { onClick() })
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,16 +145,14 @@ struct CIRcvDecryptionError: View {
|
|||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.italic()
|
.italic()
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.onTapGesture(perform: { onClick() })
|
.onTapGesture(perform: { onClick() })
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,61 +13,51 @@ import Combine
|
|||||||
|
|
||||||
struct CIVideoView: View {
|
struct CIVideoView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
private let chatItem: ChatItem
|
private let chatItem: ChatItem
|
||||||
private let image: String
|
private let preview: UIImage?
|
||||||
@State private var duration: Int
|
@State private var duration: Int
|
||||||
@State private var progress: Int = 0
|
@State private var progress: Int = 0
|
||||||
@State private var videoPlaying: Bool = false
|
@State private var videoPlaying: Bool = false
|
||||||
private let maxWidth: CGFloat
|
private let maxWidth: CGFloat
|
||||||
@Binding private var videoWidth: CGFloat?
|
private var videoWidth: CGFloat?
|
||||||
@State private var scrollProxy: ScrollViewProxy?
|
private let smallView: Bool
|
||||||
@State private var preview: UIImage? = nil
|
|
||||||
@State private var player: AVPlayer?
|
@State private var player: AVPlayer?
|
||||||
@State private var fullPlayer: AVPlayer?
|
@State private var fullPlayer: AVPlayer?
|
||||||
@State private var url: URL?
|
@State private var url: URL?
|
||||||
@State private var urlDecrypted: URL?
|
@State private var urlDecrypted: URL?
|
||||||
@State private var decryptionInProgress: Bool = false
|
@State private var decryptionInProgress: Bool = false
|
||||||
@State private var showFullScreenPlayer = false
|
@Binding private var showFullScreenPlayer: Bool
|
||||||
@State private var timeObserver: Any? = nil
|
@State private var timeObserver: Any? = nil
|
||||||
@State private var fullScreenTimeObserver: Any? = nil
|
@State private var fullScreenTimeObserver: Any? = nil
|
||||||
@State private var publisher: AnyCancellable? = nil
|
@State private var publisher: AnyCancellable? = nil
|
||||||
|
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
|
||||||
|
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||||
|
|
||||||
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
|
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
|
||||||
self.chatItem = chatItem
|
self.chatItem = chatItem
|
||||||
self.image = image
|
self.preview = preview
|
||||||
self._duration = State(initialValue: duration)
|
self._duration = State(initialValue: duration)
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
self._videoWidth = videoWidth
|
self.videoWidth = videoWidth
|
||||||
self.scrollProxy = scrollProxy
|
self.smallView = smallView
|
||||||
if let url = getLoadedVideo(chatItem.file) {
|
self._showFullScreenPlayer = showFullscreenPlayer
|
||||||
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
|
|
||||||
self._urlDecrypted = State(initialValue: decrypted)
|
|
||||||
if let decrypted = decrypted {
|
|
||||||
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
|
|
||||||
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
|
|
||||||
}
|
|
||||||
self._url = State(initialValue: url)
|
|
||||||
}
|
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
|
||||||
let uiImage = UIImage(data: data) {
|
|
||||||
self._preview = State(initialValue: uiImage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let file = chatItem.file
|
let file = chatItem.file
|
||||||
ZStack {
|
ZStack(alignment: smallView ? .topLeading : .center) {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
|
||||||
|
smallVideoView(decrypted, file, preview)
|
||||||
|
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
||||||
videoView(player, decrypted, file, preview, duration)
|
videoView(player, decrypted, file, preview, duration)
|
||||||
|
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
|
||||||
|
smallVideoViewEncrypted(file, defaultPreview)
|
||||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
||||||
videoViewEncrypted(file, defaultPreview, duration)
|
videoViewEncrypted(file, defaultPreview, duration)
|
||||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
} else if let preview, let file {
|
||||||
let uiImage = UIImage(data: data) {
|
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
|
||||||
imageView(uiImage)
|
.onTapGesture {
|
||||||
.onTapGesture {
|
|
||||||
if let file = file {
|
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
case .rcvInvitation, .rcvAborted:
|
case .rcvInvitation, .rcvAborted:
|
||||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||||
@@ -91,21 +81,64 @@ struct CIVideoView: View {
|
|||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
durationProgress()
|
if !smallView {
|
||||||
|
durationProgress()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let file = file, showDownloadButton(file.fileStatus) {
|
if !blurred, let file, showDownloadButton(file.fileStatus) {
|
||||||
Button {
|
if !smallView {
|
||||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
Button {
|
||||||
} label: {
|
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill")
|
||||||
|
}
|
||||||
|
} else if !file.showStatusIconInSmallView {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill")
|
||||||
|
.onTapGesture {
|
||||||
|
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||||
|
if let decrypted = urlDecrypted {
|
||||||
|
fullScreenPlayer(decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
setupPlayer(chatItem.file)
|
||||||
|
}
|
||||||
|
.onChange(of: chatItem.file) { file in
|
||||||
|
// ChatItem can be changed in small view on chat list screen
|
||||||
|
setupPlayer(file)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
showFullScreenPlayer = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
|
private func setupPlayer(_ file: CIFile?) {
|
||||||
|
let newUrl = getLoadedVideo(file)
|
||||||
|
if newUrl == url {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url = nil
|
||||||
|
urlDecrypted = nil
|
||||||
|
player = nil
|
||||||
|
fullPlayer = nil
|
||||||
|
if let newUrl {
|
||||||
|
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
|
||||||
|
urlDecrypted = decrypted
|
||||||
|
if let decrypted = decrypted {
|
||||||
|
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||||
|
fullPlayer = AVPlayer(url: decrypted)
|
||||||
|
}
|
||||||
|
url = newUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
|
||||||
switch fileStatus {
|
switch fileStatus {
|
||||||
case .rcvInvitation: true
|
case .rcvInvitation: true
|
||||||
case .rcvAborted: true
|
case .rcvAborted: true
|
||||||
@@ -118,11 +151,6 @@ struct CIVideoView: View {
|
|||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||||
imageView(defaultPreview)
|
imageView(defaultPreview)
|
||||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
|
||||||
if let decrypted = urlDecrypted {
|
|
||||||
fullScreenPlayer(decrypted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
decrypt(file: file) {
|
decrypt(file: file) {
|
||||||
showFullScreenPlayer = urlDecrypted != nil
|
showFullScreenPlayer = urlDecrypted != nil
|
||||||
@@ -131,20 +159,22 @@ struct CIVideoView: View {
|
|||||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
showFullScreenPlayer = false
|
showFullScreenPlayer = false
|
||||||
}
|
}
|
||||||
if !decryptionInProgress {
|
if !blurred {
|
||||||
Button {
|
if !decryptionInProgress {
|
||||||
decrypt(file: file) {
|
Button {
|
||||||
if urlDecrypted != nil {
|
decrypt(file: file) {
|
||||||
videoPlaying = true
|
if urlDecrypted != nil {
|
||||||
player?.play()
|
videoPlaying = true
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||||
}
|
}
|
||||||
} label: {
|
.disabled(!canBePlayed)
|
||||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
} else {
|
||||||
|
videoDecryptionProgress()
|
||||||
}
|
}
|
||||||
.disabled(!canBePlayed)
|
|
||||||
} else {
|
|
||||||
videoDecryptionProgress()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +182,6 @@ struct CIVideoView: View {
|
|||||||
|
|
||||||
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
||||||
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { videoWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||||
@@ -164,9 +193,7 @@ struct CIVideoView: View {
|
|||||||
videoPlaying = false
|
videoPlaying = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
|
||||||
fullScreenPlayer(url)
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
switch player.timeControlStatus {
|
switch player.timeControlStatus {
|
||||||
case .playing:
|
case .playing:
|
||||||
@@ -182,7 +209,7 @@ struct CIVideoView: View {
|
|||||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
showFullScreenPlayer = false
|
showFullScreenPlayer = false
|
||||||
}
|
}
|
||||||
if !videoPlaying {
|
if !videoPlaying && !blurred {
|
||||||
Button {
|
Button {
|
||||||
m.stopPreviousRecPlay = url
|
m.stopPreviousRecPlay = url
|
||||||
player.play()
|
player.play()
|
||||||
@@ -192,7 +219,7 @@ struct CIVideoView: View {
|
|||||||
.disabled(!canBePlayed)
|
.disabled(!canBePlayed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadingIndicator()
|
fileStatusIcon()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
addObserver(player, url)
|
addObserver(player, url)
|
||||||
@@ -204,14 +231,53 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
|
||||||
|
return ZStack(alignment: .topLeading) {
|
||||||
|
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||||
|
smallViewImageView(preview, file)
|
||||||
|
.onTapGesture {
|
||||||
|
decrypt(file: file) {
|
||||||
|
showFullScreenPlayer = urlDecrypted != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
showFullScreenPlayer = false
|
||||||
|
}
|
||||||
|
if file.showStatusIconInSmallView {
|
||||||
|
// Show nothing
|
||||||
|
} else if !decryptionInProgress {
|
||||||
|
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||||
|
} else {
|
||||||
|
videoDecryptionProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
|
||||||
|
return ZStack(alignment: .topLeading) {
|
||||||
|
smallViewImageView(preview, file)
|
||||||
|
.onTapGesture {
|
||||||
|
showFullScreenPlayer = true
|
||||||
|
}
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
showFullScreenPlayer = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !file.showStatusIconInSmallView {
|
||||||
|
playPauseIcon("play.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
|
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
|
||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 12, height: 12)
|
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, smallView ? 0 : 4)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
|
||||||
.background(Color.black.opacity(0.35))
|
.background(Color.black.opacity(0.35))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
@@ -219,50 +285,57 @@ struct CIVideoView: View {
|
|||||||
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
|
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
.frame(width: 12, height: 12)
|
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
|
||||||
.tint(color)
|
.tint(color)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
|
||||||
.background(Color.black.opacity(0.35))
|
.background(Color.black.opacity(0.35))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func durationProgress() -> some View {
|
private var fileSizeString: String {
|
||||||
HStack {
|
if let file = chatItem.file, !videoPlaying {
|
||||||
Text("\(durationText(videoPlaying ? progress : duration))")
|
" " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||||
.foregroundColor(.white)
|
} else {
|
||||||
.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 durationProgress() -> some View {
|
||||||
|
Text((durationText(videoPlaying ? progress : duration)) + fileSizeString)
|
||||||
|
.invertedForegroundStyle()
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
|
||||||
private func imageView(_ img: UIImage) -> some View {
|
private func imageView(_ img: UIImage) -> some View {
|
||||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { videoWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: w)
|
.frame(width: w)
|
||||||
loadingIndicator()
|
.modifier(PrivacyBlur(blurred: $blurred))
|
||||||
|
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
|
||||||
|
fileStatusIcon()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func loadingIndicator() -> some View {
|
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: maxWidth, height: maxWidth)
|
||||||
|
if file.showStatusIconInSmallView {
|
||||||
|
fileStatusIcon()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func fileStatusIcon() -> some View {
|
||||||
if let file = chatItem.file {
|
if let file = chatItem.file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
case .sndStored:
|
case .sndStored:
|
||||||
@@ -279,7 +352,22 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .sndError: fileIcon("xmark", 10, 13)
|
case let .sndError(sndFileError):
|
||||||
|
fileIcon("xmark", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||||
case let .rcvTransfer(rcvProgress, rcvTotal):
|
case let .rcvTransfer(rcvProgress, rcvTotal):
|
||||||
@@ -289,10 +377,25 @@ struct CIVideoView: View {
|
|||||||
progressView()
|
progressView()
|
||||||
}
|
}
|
||||||
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||||
|
case .rcvComplete: EmptyView()
|
||||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .rcvError: fileIcon("xmark", 10, 13)
|
case let .rcvError(rcvFileError):
|
||||||
|
fileIcon("xmark", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .invalid: fileIcon("questionmark", 10, 13)
|
case .invalid: fileIcon("questionmark", 10, 13)
|
||||||
default: EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,10 +403,10 @@ struct CIVideoView: View {
|
|||||||
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
|
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.resizable()
|
.resizable()
|
||||||
|
.invertedForegroundStyle()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(.white)
|
.padding(smallView ? 0 : padding)
|
||||||
.padding(padding)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func progressView() -> some View {
|
private func progressView() -> some View {
|
||||||
@@ -311,19 +414,17 @@ struct CIVideoView: View {
|
|||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
.padding(11)
|
.padding(smallView ? 0 : 11)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: Double(progress) / Double(total))
|
.trim(from: 0, to: Double(progress) / Double(total))
|
||||||
.stroke(
|
.stroke(style: StrokeStyle(lineWidth: 2))
|
||||||
Color(uiColor: .white),
|
.invertedForegroundStyle()
|
||||||
style: StrokeStyle(lineWidth: 2)
|
|
||||||
)
|
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.padding([.trailing, .top], 11)
|
.padding([.trailing, .top], smallView ? 0 : 11)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO encrypt: where file size is checked?
|
// TODO encrypt: where file size is checked?
|
||||||
@@ -363,7 +464,8 @@ struct CIVideoView: View {
|
|||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||||
m.stopPreviousRecPlay = url
|
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
|
||||||
|
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
|
||||||
if let player = fullPlayer {
|
if let player = fullPlayer {
|
||||||
player.play()
|
player.play()
|
||||||
var played = false
|
var played = false
|
||||||
@@ -400,10 +502,12 @@ struct CIVideoView: View {
|
|||||||
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
|
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let decrypted = urlDecrypted {
|
if let decrypted = urlDecrypted {
|
||||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
if !smallView {
|
||||||
|
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||||
|
}
|
||||||
fullPlayer = AVPlayer(url: decrypted)
|
fullPlayer = AVPlayer(url: decrypted)
|
||||||
}
|
}
|
||||||
decryptionInProgress = true
|
decryptionInProgress = false
|
||||||
completed?()
|
completed?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,30 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIVoiceView: View {
|
struct CIVoiceView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
let recordingFile: CIFile?
|
let recordingFile: CIFile?
|
||||||
let duration: Int
|
let duration: Int
|
||||||
@Binding var audioPlayer: AudioPlayer?
|
@State var audioPlayer: AudioPlayer? = nil
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||||
@Binding var playbackTime: TimeInterval?
|
@State var playbackTime: TimeInterval? = nil
|
||||||
|
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
|
var smallViewSize: CGFloat?
|
||||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if chatItem.chatDir.sent {
|
if smallViewSize != nil {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
player()
|
||||||
|
playerTime()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||||
|
playbackSlider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if chatItem.chatDir.sent {
|
||||||
VStack (alignment: .trailing, spacing: 6) {
|
VStack (alignment: .trailing, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||||
@@ -53,7 +65,13 @@ struct CIVoiceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func player() -> some View {
|
private func player() -> some View {
|
||||||
VoiceMessagePlayer(
|
let sizeMultiplier: CGFloat = if let sz = smallViewSize {
|
||||||
|
voiceMessageSizeBasedOnSquareSize(sz) / 56
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
return VoiceMessagePlayer(
|
||||||
|
chat: chat,
|
||||||
chatItem: chatItem,
|
chatItem: chatItem,
|
||||||
recordingFile: recordingFile,
|
recordingFile: recordingFile,
|
||||||
recordingTime: TimeInterval(duration),
|
recordingTime: TimeInterval(duration),
|
||||||
@@ -62,7 +80,8 @@ struct CIVoiceView: View {
|
|||||||
audioPlayer: $audioPlayer,
|
audioPlayer: $audioPlayer,
|
||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime,
|
playbackTime: $playbackTime,
|
||||||
allowMenu: $allowMenu
|
allowMenu: $allowMenu,
|
||||||
|
sizeMultiplier: sizeMultiplier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +91,7 @@ struct CIVoiceView: View {
|
|||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime
|
playbackTime: $playbackTime
|
||||||
)
|
)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playbackSlider() -> some View {
|
private func playbackSlider() -> some View {
|
||||||
@@ -89,10 +108,11 @@ struct CIVoiceView: View {
|
|||||||
allowMenu = true
|
allowMenu = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func metaView() -> some View {
|
private func metaView() -> some View {
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,8 +137,9 @@ struct VoiceMessagePlayerTime: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct VoiceMessagePlayer: View {
|
struct VoiceMessagePlayer: View {
|
||||||
|
@ObservedObject var chat: Chat
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var recordingFile: CIFile?
|
var recordingFile: CIFile?
|
||||||
var recordingTime: TimeInterval
|
var recordingTime: TimeInterval
|
||||||
@@ -128,24 +149,61 @@ struct VoiceMessagePlayer: View {
|
|||||||
@Binding var audioPlayer: AudioPlayer?
|
@Binding var audioPlayer: AudioPlayer?
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
@Binding var playbackState: VoiceMessagePlaybackState
|
||||||
@Binding var playbackTime: TimeInterval?
|
@Binding var playbackTime: TimeInterval?
|
||||||
|
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
|
var sizeMultiplier: CGFloat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if let recordingFile = recordingFile {
|
if let recordingFile = recordingFile {
|
||||||
switch recordingFile.fileStatus {
|
switch recordingFile.fileStatus {
|
||||||
case .sndStored: playbackButton()
|
case .sndStored:
|
||||||
case .sndTransfer: playbackButton()
|
if recordingFile.fileProtocol == .local {
|
||||||
|
playbackButton()
|
||||||
|
} else {
|
||||||
|
loadingIcon()
|
||||||
|
}
|
||||||
|
case .sndTransfer: loadingIcon()
|
||||||
case .sndComplete: playbackButton()
|
case .sndComplete: playbackButton()
|
||||||
case .sndCancelled: playbackButton()
|
case .sndCancelled: playbackButton()
|
||||||
case .sndError: playbackButton()
|
case let .sndError(sndFileError):
|
||||||
|
fileStatusIcon("multiply", 14)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
fileStatusIcon("exclamationmark.triangle.fill", 16)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
|
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
|
||||||
case .rcvAccepted: loadingIcon()
|
case .rcvAccepted: loadingIcon()
|
||||||
case .rcvTransfer: loadingIcon()
|
case .rcvTransfer: loadingIcon()
|
||||||
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
|
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
|
||||||
case .rcvComplete: playbackButton()
|
case .rcvComplete: playbackButton()
|
||||||
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case let .rcvError(rcvFileError):
|
||||||
|
fileStatusIcon("multiply", 14)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
fileStatusIcon("exclamationmark.triangle.fill", 16)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -153,84 +211,170 @@ struct VoiceMessagePlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
if audioPlayer == nil {
|
||||||
|
let small = sizeMultiplier != 1
|
||||||
|
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
|
||||||
|
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
|
||||||
|
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
|
||||||
|
}
|
||||||
seek = { to in audioPlayer?.seek(to) }
|
seek = { to in audioPlayer?.seek(to) }
|
||||||
audioPlayer?.onTimer = { playbackTime = $0 }
|
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||||
|
getAppFilePath(recordingSource.filePath)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
let chatId = chatModel.chatId
|
||||||
|
let userId = chatModel.currentUser?.userId
|
||||||
|
audioPlayer?.onTimer = {
|
||||||
|
playbackTime = $0
|
||||||
|
notifyStateChange()
|
||||||
|
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
|
||||||
|
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
|
||||||
|
stopPlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
audioPlayer?.onFinishPlayback = {
|
audioPlayer?.onFinishPlayback = {
|
||||||
playbackState = .noPlayback
|
playbackState = .noPlayback
|
||||||
playbackTime = TimeInterval(0)
|
playbackTime = TimeInterval(0)
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
|
||||||
|
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
|
||||||
|
stopPlayback()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: chatModel.stopPreviousRecPlay) { it in
|
.onChange(of: chatModel.stopPreviousRecPlay) { it in
|
||||||
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||||
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||||
audioPlayer?.stop()
|
stopPlayback()
|
||||||
playbackState = .noPlayback
|
|
||||||
playbackTime = TimeInterval(0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: playbackState) { state in
|
.onChange(of: playbackState) { state in
|
||||||
allowMenu = state == .paused || state == .noPlayback
|
allowMenu = state == .paused || state == .noPlayback
|
||||||
|
// Notify activeContentPreview in ChatPreviewView that playback is finished
|
||||||
|
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||||
|
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
|
||||||
|
chatModel.stopPreviousRecPlay = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: chatModel.chatId) { _ in
|
||||||
|
stopPlayback()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
if sizeMultiplier == 1 && chatModel.chatId == nil {
|
||||||
|
stopPlayback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func playbackButton() -> some View {
|
@ViewBuilder private func playbackButton() -> some View {
|
||||||
switch playbackState {
|
if sizeMultiplier != 1 {
|
||||||
case .noPlayback:
|
switch playbackState {
|
||||||
Button {
|
case .noPlayback:
|
||||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
startPlayback(recordingSource)
|
.onTapGesture {
|
||||||
|
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||||
|
startPlayback(recordingSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .playing:
|
||||||
|
playPauseIcon("pause.fill", theme.colors.primary)
|
||||||
|
.onTapGesture {
|
||||||
|
audioPlayer?.pause()
|
||||||
|
playbackState = .paused
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
case .paused:
|
||||||
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
|
.onTapGesture {
|
||||||
|
audioPlayer?.play()
|
||||||
|
playbackState = .playing
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch playbackState {
|
||||||
|
case .noPlayback:
|
||||||
|
Button {
|
||||||
|
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||||
|
startPlayback(recordingSource)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
|
}
|
||||||
|
case .playing:
|
||||||
|
Button {
|
||||||
|
audioPlayer?.pause()
|
||||||
|
playbackState = .paused
|
||||||
|
notifyStateChange()
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("pause.fill", theme.colors.primary)
|
||||||
|
}
|
||||||
|
case .paused:
|
||||||
|
Button {
|
||||||
|
audioPlayer?.play()
|
||||||
|
playbackState = .playing
|
||||||
|
notifyStateChange()
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
playPauseIcon("play.fill")
|
|
||||||
}
|
|
||||||
case .playing:
|
|
||||||
Button {
|
|
||||||
audioPlayer?.pause()
|
|
||||||
playbackState = .paused
|
|
||||||
} label: {
|
|
||||||
playPauseIcon("pause.fill")
|
|
||||||
}
|
|
||||||
case .paused:
|
|
||||||
Button {
|
|
||||||
audioPlayer?.play()
|
|
||||||
playbackState = .playing
|
|
||||||
} label: {
|
|
||||||
playPauseIcon("play.fill")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.padding(.leading, image == "play.fill" ? 4 : 0)
|
.padding(.leading, image == "play.fill" ? 4 : 0)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
if recordingTime > 0 {
|
if recordingTime > 0 {
|
||||||
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
||||||
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
|
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
|
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
|
||||||
Button {
|
Group {
|
||||||
Task {
|
if sizeMultiplier != 1 {
|
||||||
if let user = chatModel.currentUser {
|
playPauseIcon(icon, theme.colors.primary)
|
||||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
.onTapGesture {
|
||||||
|
Task {
|
||||||
|
if let user = chatModel.currentUser {
|
||||||
|
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
if let user = chatModel.currentUser {
|
||||||
|
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
playPauseIcon(icon, theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
}
|
||||||
playPauseIcon(icon)
|
}
|
||||||
|
|
||||||
|
func notifyStateChange() {
|
||||||
|
if sizeMultiplier != 1 {
|
||||||
|
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
|
||||||
|
} else {
|
||||||
|
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ProgressCircle: View {
|
private struct ProgressCircle: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var length: TimeInterval
|
var length: TimeInterval
|
||||||
@Binding var progress: TimeInterval?
|
@Binding var progress: TimeInterval?
|
||||||
|
|
||||||
@@ -238,7 +382,7 @@ struct VoiceMessagePlayer: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
||||||
.stroke(
|
.stroke(
|
||||||
Color.accentColor,
|
theme.colors.primary,
|
||||||
style: StrokeStyle(lineWidth: 3)
|
style: StrokeStyle(lineWidth: 3)
|
||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
@@ -246,26 +390,100 @@ struct VoiceMessagePlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fileStatusIcon(_ image: String, _ size: CGFloat) -> some View {
|
||||||
|
Image(systemName: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
|
||||||
|
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||||
|
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||||
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
private func loadingIcon() -> some View {
|
private func loadingIcon() -> some View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startPlayback(_ recordingSource: CryptoFile) {
|
private func startPlayback(_ recordingSource: CryptoFile) {
|
||||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
|
let audioPath = getAppFilePath(recordingSource.filePath)
|
||||||
|
let chatId = chatModel.chatId
|
||||||
|
let userId = chatModel.currentUser?.userId
|
||||||
|
chatModel.stopPreviousRecPlay = audioPath
|
||||||
audioPlayer = AudioPlayer(
|
audioPlayer = AudioPlayer(
|
||||||
onTimer: { playbackTime = $0 },
|
onTimer: {
|
||||||
|
playbackTime = $0
|
||||||
|
notifyStateChange()
|
||||||
|
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
|
||||||
|
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
|
||||||
|
stopPlayback()
|
||||||
|
}
|
||||||
|
},
|
||||||
onFinishPlayback: {
|
onFinishPlayback: {
|
||||||
playbackState = .noPlayback
|
playbackState = .noPlayback
|
||||||
playbackTime = TimeInterval(0)
|
playbackTime = TimeInterval(0)
|
||||||
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
||||||
playbackState = .playing
|
playbackState = .playing
|
||||||
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func stopPlayback() {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
playbackState = .noPlayback
|
||||||
|
playbackTime = TimeInterval(0)
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
|
||||||
|
let squareToCircleRatio = 0.935
|
||||||
|
return squareSize + squareSize * (1 - squareToCircleRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
class VoiceItemState {
|
||||||
|
var audioPlayer: AudioPlayer?
|
||||||
|
var playbackState: VoiceMessagePlaybackState
|
||||||
|
var playbackTime: TimeInterval?
|
||||||
|
|
||||||
|
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
|
||||||
|
self.audioPlayer = audioPlayer
|
||||||
|
self.playbackState = playbackState
|
||||||
|
self.playbackTime = playbackTime
|
||||||
|
}
|
||||||
|
|
||||||
|
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
|
||||||
|
"\(chat.id) \(chatItem.id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
|
||||||
|
"\(chatInfo.id) \(chatItem.id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
|
||||||
|
let id = id(chatInfo, chatItem)
|
||||||
|
if let item = smallView[id] {
|
||||||
|
item.audioPlayer?.stop()
|
||||||
|
ChatModel.shared.stopPreviousRecPlay = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
|
||||||
|
let id = id(chatInfo, chatItem)
|
||||||
|
if let item = chatView[id] {
|
||||||
|
item.audioPlayer?.stop()
|
||||||
|
ChatModel.shared.stopPreviousRecPlay = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var smallView: [String: VoiceItemState] = [:]
|
||||||
|
static var chatView: [String: VoiceItemState] = [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CIVoiceView_Previews: PreviewProvider {
|
struct CIVoiceView_Previews: PreviewProvider {
|
||||||
@@ -290,15 +508,12 @@ struct CIVoiceView_Previews: PreviewProvider {
|
|||||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||||
duration: 30,
|
duration: 30,
|
||||||
audioPlayer: .constant(nil),
|
|
||||||
playbackState: .constant(.playing),
|
|
||||||
playbackTime: .constant(TimeInterval(20)),
|
|
||||||
allowMenu: Binding.constant(true)
|
allowMenu: Binding.constant(true)
|
||||||
)
|
)
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .constant(true))
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 360))
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,21 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct DeletedItemView: View {
|
struct DeletedItemView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .bottom, spacing: 0) {
|
HStack(alignment: .bottom, spacing: 0) {
|
||||||
Text(chatItem.content.text)
|
Text(chatItem.content.text)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.italic()
|
.italic()
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct EmojiItemView: View {
|
struct EmojiItemView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -18,7 +19,7 @@ struct EmojiItemView: View {
|
|||||||
emojiText(chatItem.content.text)
|
emojiText(chatItem.content.text)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,24 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct FramedCIVoiceView: View {
|
struct FramedCIVoiceView: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
let recordingFile: CIFile?
|
let recordingFile: CIFile?
|
||||||
let duration: Int
|
let duration: Int
|
||||||
|
|
||||||
|
@State var audioPlayer: AudioPlayer? = nil
|
||||||
|
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||||
|
@State var playbackTime: TimeInterval? = nil
|
||||||
|
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
|
|
||||||
@Binding var audioPlayer: AudioPlayer?
|
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
|
||||||
@Binding var playbackTime: TimeInterval?
|
|
||||||
|
|
||||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
VoiceMessagePlayer(
|
VoiceMessagePlayer(
|
||||||
|
chat: chat,
|
||||||
chatItem: chatItem,
|
chatItem: chatItem,
|
||||||
recordingFile: recordingFile,
|
recordingFile: recordingFile,
|
||||||
recordingTime: TimeInterval(duration),
|
recordingTime: TimeInterval(duration),
|
||||||
@@ -35,14 +38,15 @@ struct FramedCIVoiceView: View {
|
|||||||
audioPlayer: $audioPlayer,
|
audioPlayer: $audioPlayer,
|
||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime,
|
playbackTime: $playbackTime,
|
||||||
allowMenu: $allowMenu
|
allowMenu: $allowMenu,
|
||||||
|
sizeMultiplier: 1
|
||||||
)
|
)
|
||||||
VoiceMessagePlayerTime(
|
VoiceMessagePlayerTime(
|
||||||
recordingTime: TimeInterval(duration),
|
recordingTime: TimeInterval(duration),
|
||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime
|
playbackTime: $playbackTime
|
||||||
)
|
)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.frame(width: 50, alignment: .leading)
|
.frame(width: 50, alignment: .leading)
|
||||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||||
playbackSlider()
|
playbackSlider()
|
||||||
@@ -88,12 +92,13 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
|||||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||||
)
|
)
|
||||||
Group {
|
Group {
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage)
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote)
|
||||||
}
|
}
|
||||||
|
.environment(\.revealed, false)
|
||||||
.previewLayout(.fixed(width: 360, height: 360))
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import AVKit
|
|||||||
|
|
||||||
struct FullScreenMediaView: View {
|
struct FullScreenMediaView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var scrollModel: ReverseListScrollModel
|
||||||
@State var chatItem: ChatItem
|
@State var chatItem: ChatItem
|
||||||
@State var image: UIImage?
|
@State var image: UIImage?
|
||||||
@State var player: AVPlayer? = nil
|
@State var player: AVPlayer? = nil
|
||||||
@State var url: URL? = nil
|
@State var url: URL? = nil
|
||||||
@Binding var showView: Bool
|
@Binding var showView: Bool
|
||||||
@State var scrollProxy: ScrollViewProxy?
|
|
||||||
@State private var showNext = false
|
@State private var showNext = false
|
||||||
@State private var nextImage: UIImage?
|
@State private var nextImage: UIImage?
|
||||||
@State private var nextPlayer: AVPlayer?
|
@State private var nextPlayer: AVPlayer?
|
||||||
@@ -71,9 +71,7 @@ struct FullScreenMediaView: View {
|
|||||||
let w = abs(t.width)
|
let w = abs(t.width)
|
||||||
if t.height > 60 && t.height > w * 2 {
|
if t.height > 60 && t.height > w * 2 {
|
||||||
showView = false
|
showView = false
|
||||||
if let proxy = scrollProxy {
|
scrollModel.scrollToItem(id: chatItem.id)
|
||||||
proxy.scrollTo(chatItem.viewId)
|
|
||||||
}
|
|
||||||
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
|
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
|
||||||
let previous = t.width > 0
|
let previous = t.width > 0
|
||||||
scrolling = true
|
scrolling = true
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct IntegrityErrorItemView: View {
|
struct IntegrityErrorItemView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var msgError: MsgErrorType
|
var msgError: MsgErrorType
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ struct IntegrityErrorItemView: View {
|
|||||||
|
|
||||||
struct CIMsgError: View {
|
struct CIMsgError: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var onTap: () -> Void
|
var onTap: () -> Void
|
||||||
|
|
||||||
@@ -62,13 +64,12 @@ struct CIMsgError: View {
|
|||||||
Text(chatItem.content.text)
|
Text(chatItem.content.text)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.italic()
|
.italic()
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onTapGesture(perform: onTap)
|
.onTapGesture(perform: onTap)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,19 +11,18 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct MarkedDeletedItemView: View {
|
struct MarkedDeletedItemView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.revealed) var revealed: Bool
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@Binding var revealed: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
|
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +35,8 @@ struct MarkedDeletedItemView: View {
|
|||||||
var blockedByAdmin = 0
|
var blockedByAdmin = 0
|
||||||
var deleted = 0
|
var deleted = 0
|
||||||
var moderatedBy: Set<String> = []
|
var moderatedBy: Set<String> = []
|
||||||
while i < m.reversedChatItems.count,
|
while i < ItemsModel.shared.reversedChatItems.count,
|
||||||
let ci = .some(m.reversedChatItems[i]),
|
let ci = .some(ItemsModel.shared.reversedChatItems[i]),
|
||||||
ci.mergeCategory == ciCategory,
|
ci.mergeCategory == ciCategory,
|
||||||
let itemDeleted = ci.meta.itemDeleted {
|
let itemDeleted = ci.meta.itemDeleted {
|
||||||
switch itemDeleted {
|
switch itemDeleted {
|
||||||
@@ -80,7 +79,10 @@ struct MarkedDeletedItemView: View {
|
|||||||
struct MarkedDeletedItemView_Previews: PreviewProvider {
|
struct MarkedDeletedItemView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
|
MarkedDeletedItemView(
|
||||||
|
chat: Chat.sampleData,
|
||||||
|
chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
|
||||||
|
).environment(\.revealed, true)
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 200))
|
.previewLayout(.fixed(width: 360, height: 200))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ private func typing(_ w: Font.Weight = .light) -> Text {
|
|||||||
|
|
||||||
struct MsgContentView: View {
|
struct MsgContentView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var text: String
|
var text: String
|
||||||
var formattedText: [FormattedText]? = nil
|
var formattedText: [FormattedText]? = nil
|
||||||
var sender: String? = nil
|
var sender: String? = nil
|
||||||
@@ -65,7 +67,7 @@ struct MsgContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func msgContentView() -> Text {
|
private func msgContentView() -> Text {
|
||||||
var v = messageText(text, formattedText, sender, showSecrets: showSecrets)
|
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)
|
||||||
if let mt = meta {
|
if let mt = meta {
|
||||||
if mt.isLive {
|
if mt.isLive {
|
||||||
v = v + typingIndicator(mt.recent)
|
v = v + typingIndicator(mt.recent)
|
||||||
@@ -79,15 +81,15 @@ struct MsgContentView: View {
|
|||||||
return (recent ? typingIndicators[typingIdx] : noTyping)
|
return (recent ? typingIndicators[typingIdx] : noTyping)
|
||||||
.font(.body.monospaced())
|
.font(.body.monospaced())
|
||||||
.kerning(-2)
|
.kerning(-2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool) -> Text {
|
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text {
|
||||||
let s = text
|
let s = text
|
||||||
var res: Text
|
var res: Text
|
||||||
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
||||||
@@ -102,7 +104,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let i = icon {
|
if let i = icon {
|
||||||
res = Text(Image(systemName: i)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ") + res
|
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res
|
||||||
}
|
}
|
||||||
|
|
||||||
if let s = sender {
|
if let s = sender {
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ChatItemForwardingView: View {
|
struct ChatItemForwardingView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
var ci: ChatItem
|
var chatItems: [ChatItem]
|
||||||
var fromChatInfo: ChatInfo
|
var fromChatInfo: ChatInfo
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
|
|
||||||
@State private var searchText: String = ""
|
@State private var searchText: String = ""
|
||||||
@FocusState private var searchFocused
|
@State private var alert: SomeAlert?
|
||||||
|
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
@@ -35,98 +37,76 @@ struct ChatItemForwardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground())
|
||||||
|
.alert(item: $alert) { $0.alert }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func forwardListView() -> some View {
|
@ViewBuilder private func forwardListView() -> some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
let chatsToForwardTo = filterChatsToForwardTo()
|
|
||||||
if !chatsToForwardTo.isEmpty {
|
if !chatsToForwardTo.isEmpty {
|
||||||
ScrollView {
|
List {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||||
searchFieldView(text: $searchText, focussed: $searchFocused)
|
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
|
||||||
.padding(.leading, 2)
|
ForEach(chats) { chat in
|
||||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
forwardListChatView(chat)
|
||||||
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { filterChatSearched($0, s) }
|
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||||
ForEach(chats) { chat in
|
|
||||||
Divider()
|
|
||||||
forwardListNavLinkView(chat)
|
|
||||||
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color(uiColor: .systemBackground))
|
|
||||||
.cornerRadius(12)
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
ZStack {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func filterChatsToForwardTo() -> [Chat] {
|
|
||||||
var filteredChats = chatModel.chats.filter({ canForwardToChat($0) })
|
|
||||||
if let index = filteredChats.firstIndex(where: { $0.chatInfo.chatType == .local }) {
|
|
||||||
let privateNotes = filteredChats.remove(at: index)
|
|
||||||
filteredChats.insert(privateNotes, at: 0)
|
|
||||||
}
|
|
||||||
return filteredChats
|
|
||||||
}
|
|
||||||
|
|
||||||
private func filterChatSearched(_ chat: Chat, _ searchStr: String) -> Bool {
|
|
||||||
let cInfo = chat.chatInfo
|
|
||||||
return switch cInfo {
|
|
||||||
case let .direct(contact):
|
|
||||||
viewNameContains(cInfo, searchStr) ||
|
|
||||||
contact.profile.displayName.localizedLowercase.contains(searchStr) ||
|
|
||||||
contact.fullName.localizedLowercase.contains(searchStr)
|
|
||||||
default:
|
|
||||||
viewNameContains(cInfo, searchStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
|
|
||||||
cInfo.chatViewName.localizedLowercase.contains(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func canForwardToChat(_ chat: Chat) -> Bool {
|
|
||||||
switch chat.chatInfo {
|
|
||||||
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
|
|
||||||
case let .group(groupInfo): groupInfo.sendMsgEnabled
|
|
||||||
case let .local(noteFolder): noteFolder.sendMsgEnabled
|
|
||||||
case .contactRequest: false
|
|
||||||
case .contactConnection: false
|
|
||||||
case .invalidJSON: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func emptyList() -> some View {
|
private func emptyList() -> some View {
|
||||||
Text("No filtered chats")
|
Text("No filtered chats")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func forwardListNavLinkView(_ chat: Chat) -> some View {
|
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
|
||||||
|
let prohibited = chatItems.map { ci in
|
||||||
|
chat.prohibitedByPref(
|
||||||
|
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
|
||||||
|
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
|
||||||
|
isVoice: ci.content.msgContent?.isVoice ?? false
|
||||||
|
)
|
||||||
|
}.contains(true)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
dismiss()
|
if prohibited {
|
||||||
if chat.id == fromChatInfo.id {
|
alert = SomeAlert(
|
||||||
composeState = ComposeState(
|
alert: mkAlert(
|
||||||
message: composeState.message,
|
title: "Cannot forward message",
|
||||||
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
|
message: "Selected chat preferences prohibit this message."
|
||||||
contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
|
),
|
||||||
)
|
id: "forward prohibited by preferences"
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
|
dismiss()
|
||||||
chatModel.chatId = chat.id
|
if chat.id == fromChatInfo.id {
|
||||||
|
composeState = ComposeState(
|
||||||
|
message: composeState.message,
|
||||||
|
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
|
||||||
|
contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo)
|
||||||
|
ItemsModel.shared.loadOpenChat(chat.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
ChatInfoImage(chat: chat, size: 30)
|
ChatInfoImage(chat: chat, size: 30)
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
Text(chat.chatInfo.chatViewName)
|
Text(chat.chatInfo.chatViewName)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(prohibited ? theme.colors.secondary : theme.colors.onBackground)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if chat.chatInfo.incognito {
|
if chat.chatInfo.incognito {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -134,7 +114,7 @@ struct ChatItemForwardingView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: 22, height: 22)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -144,8 +124,9 @@ struct ChatItemForwardingView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ChatItemForwardingView(
|
ChatItemForwardingView(
|
||||||
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
|
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")],
|
||||||
fromChatInfo: .direct(contact: Contact.sampleData),
|
fromChatInfo: .direct(contact: Contact.sampleData),
|
||||||
composeState: Binding.constant(ComposeState(message: "hello"))
|
composeState: Binding.constant(ComposeState(message: "hello"))
|
||||||
)
|
).environmentObject(CurrentColors.toAppTheme())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import SimpleXChat
|
|||||||
struct ChatItemInfoView: View {
|
struct ChatItemInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var ci: ChatItem
|
var ci: ChatItem
|
||||||
@Binding var chatItemInfo: ChatItemInfo?
|
@Binding var chatItemInfo: ChatItemInfo?
|
||||||
@State private var selection: CIInfoTab = .history
|
@State private var selection: CIInfoTab = .history
|
||||||
@State private var alert: CIInfoViewAlert? = nil
|
@State private var alert: CIInfoViewAlert? = nil
|
||||||
|
@State private var messageStatusLimited: Bool = true
|
||||||
|
@State private var fileStatusLimited: Bool = true
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
|
|
||||||
enum CIInfoTab {
|
enum CIInfoTab {
|
||||||
@@ -99,12 +101,14 @@ struct ChatItemInfoView: View {
|
|||||||
Label("History", systemImage: "clock")
|
Label("History", systemImage: "clock")
|
||||||
}
|
}
|
||||||
.tag(CIInfoTab.history)
|
.tag(CIInfoTab.history)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
if let qi = ci.quotedItem {
|
if let qi = ci.quotedItem {
|
||||||
quoteTab(qi)
|
quoteTab(qi)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
||||||
}
|
}
|
||||||
.tag(CIInfoTab.quote)
|
.tag(CIInfoTab.quote)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem {
|
if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem {
|
||||||
forwardedFromTab(forwardedFromItem)
|
forwardedFromTab(forwardedFromItem)
|
||||||
@@ -112,6 +116,7 @@ struct ChatItemInfoView: View {
|
|||||||
Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward")
|
Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward")
|
||||||
}
|
}
|
||||||
.tag(CIInfoTab.forwarded)
|
.tag(CIInfoTab.forwarded)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -121,6 +126,7 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
historyTab()
|
historyTab()
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +163,35 @@ struct ChatItemInfoView: View {
|
|||||||
if developerTools {
|
if developerTools {
|
||||||
infoRow("Database ID", "\(meta.itemId)")
|
infoRow("Database ID", "\(meta.itemId)")
|
||||||
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
||||||
|
let msv = infoRow("Message status", ci.meta.itemStatus.id)
|
||||||
|
Group {
|
||||||
|
if messageStatusLimited {
|
||||||
|
msv.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
msv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation {
|
||||||
|
messageStatusLimited.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let file = ci.file {
|
||||||
|
let fsv = infoRow("File status", file.fileStatus.id)
|
||||||
|
Group {
|
||||||
|
if fileStatusLimited {
|
||||||
|
fsv.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
fsv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation {
|
||||||
|
fileStatusLimited.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +216,7 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Text("No history")
|
Text("No history")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,8 +231,8 @@ struct ChatItemInfoView: View {
|
|||||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(ci, colorScheme))
|
.background(chatItemFrameColor(ci, theme))
|
||||||
.cornerRadius(18)
|
.modifier(ChatItemClipped())
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if itemVersion.msgContent.text != "" {
|
if itemVersion.msgContent.text != "" {
|
||||||
Button {
|
Button {
|
||||||
@@ -227,18 +262,19 @@ struct ChatItemInfoView: View {
|
|||||||
} else {
|
} else {
|
||||||
Text("no text")
|
Text("no text")
|
||||||
.italic()
|
.italic()
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct TextBubble: View {
|
private struct TextBubble: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var text: String
|
var text: String
|
||||||
var formattedText: [FormattedText]?
|
var formattedText: [FormattedText]?
|
||||||
var sender: String? = nil
|
var sender: String? = nil
|
||||||
@State private var showSecrets = false
|
@State private var showSecrets = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets))
|
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +301,8 @@ struct ChatItemInfoView: View {
|
|||||||
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(quotedMsgFrameColor(qi, colorScheme))
|
.background(quotedMsgFrameColor(qi, theme))
|
||||||
.cornerRadius(18)
|
.modifier(ChatItemClipped())
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if qi.text != "" {
|
if qi.text != "" {
|
||||||
Button {
|
Button {
|
||||||
@@ -289,10 +325,10 @@ struct ChatItemInfoView: View {
|
|||||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
|
func quotedMsgFrameColor(_ qi: CIQuote, _ theme: AppTheme) -> Color {
|
||||||
(qi.chatDir?.sent ?? false)
|
(qi.chatDir?.sent ?? false)
|
||||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
? theme.appColors.sentMessage
|
||||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
: theme.appColors.receivedMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
|
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
|
||||||
@@ -315,7 +351,7 @@ struct ChatItemInfoView: View {
|
|||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
chatModel.chatId = forwardedFromItem.chatInfo.id
|
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,7 +363,7 @@ struct ChatItemInfoView: View {
|
|||||||
Divider().padding(.top, 32)
|
Divider().padding(.top, 32)
|
||||||
Text("Recipient(s) can't see who this message is from.")
|
Text("Recipient(s) can't see who this message is from.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,23 +377,23 @@ struct ChatItemInfoView: View {
|
|||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("you")
|
Text("you")
|
||||||
.italic()
|
.italic()
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
Text(forwardedFromItem.chatInfo.chatViewName)
|
Text(forwardedFromItem.chatInfo.chatViewName)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
|
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(groupMember.chatViewName)
|
Text(groupMember.chatViewName)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(forwardedFromItem.chatInfo.chatViewName)
|
Text(forwardedFromItem.chatInfo.chatViewName)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(forwardedFromItem.chatInfo.chatViewName)
|
Text(forwardedFromItem.chatInfo.chatViewName)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,7 +415,7 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
LazyVStack(alignment: .leading, spacing: 12) {
|
||||||
let mss = membersStatuses(memberDeliveryStatuses)
|
let mss = membersStatuses(memberDeliveryStatuses)
|
||||||
if !mss.isEmpty {
|
if !mss.isEmpty {
|
||||||
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
||||||
@@ -387,12 +423,12 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No delivery information")
|
Text("No delivery information")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, Bool?)] {
|
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] {
|
||||||
memberDeliveryStatuses.compactMap({ mds in
|
memberDeliveryStatuses.compactMap({ mds in
|
||||||
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
||||||
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
|
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
|
||||||
@@ -402,36 +438,20 @@ struct ChatItemInfoView: View {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View {
|
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
|
||||||
HStack{
|
HStack{
|
||||||
ProfileImage(imageStr: member.image, size: 30)
|
MemberProfileImage(member, size: 30)
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
Text(member.chatViewName)
|
Text(member.chatViewName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
if sentViaProxy == true {
|
if sentViaProxy == true {
|
||||||
Image(systemName: "arrow.forward")
|
Image(systemName: "arrow.forward")
|
||||||
.foregroundColor(.secondary).opacity(0.67)
|
.foregroundColor(theme.colors.secondary).opacity(0.67)
|
||||||
}
|
}
|
||||||
let v = Group {
|
let v = Group {
|
||||||
if let (icon, statusColor) = status.statusIcon(Color.secondary) {
|
let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
|
||||||
switch status {
|
image.foregroundColor(statusColor)
|
||||||
case .sndRcvd:
|
|
||||||
ZStack(alignment: .trailing) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundColor(statusColor.opacity(0.67))
|
|
||||||
.padding(.trailing, 6)
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundColor(statusColor.opacity(0.67))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundColor(statusColor)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Image(systemName: "ellipsis")
|
|
||||||
.foregroundColor(Color.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let (title, text) = status.statusInfo {
|
if let (title, text) = status.statusInfo {
|
||||||
@@ -473,8 +493,12 @@ struct ChatItemInfoView: View {
|
|||||||
if developerTools {
|
if developerTools {
|
||||||
shareText += [
|
shareText += [
|
||||||
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
|
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
|
||||||
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt))
|
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt)),
|
||||||
|
String.localizedStringWithFormat(NSLocalizedString("Message status: %@", comment: "copied message info"), meta.itemStatus.id)
|
||||||
]
|
]
|
||||||
|
if let file = ci.file {
|
||||||
|
shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let qi = ci.quotedItem {
|
if let qi = ci.quotedItem {
|
||||||
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
|
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
|
||||||
|
|||||||
@@ -9,50 +9,59 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
struct ShowTimestamp: EnvironmentKey {
|
||||||
|
static let defaultValue: Bool = true
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Revealed: EnvironmentKey {
|
||||||
|
static let defaultValue: Bool = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var showTimestamp: Bool {
|
||||||
|
get { self[ShowTimestamp.self] }
|
||||||
|
set { self[ShowTimestamp.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var revealed: Bool {
|
||||||
|
get { self[Revealed.self] }
|
||||||
|
set { self[Revealed.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatItemView: View {
|
struct ChatItemView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||||
|
@Environment(\.revealed) var revealed: Bool
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var maxWidth: CGFloat = .infinity
|
var maxWidth: CGFloat = .infinity
|
||||||
@State var scrollProxy: ScrollViewProxy? = nil
|
|
||||||
@Binding var revealed: Bool
|
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
@Binding var audioPlayer: AudioPlayer?
|
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
|
||||||
@Binding var playbackTime: TimeInterval?
|
|
||||||
init(
|
init(
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
chatItem: ChatItem,
|
chatItem: ChatItem,
|
||||||
showMember: Bool = false,
|
showMember: Bool = false,
|
||||||
maxWidth: CGFloat = .infinity,
|
maxWidth: CGFloat = .infinity,
|
||||||
scrollProxy: ScrollViewProxy? = nil,
|
allowMenu: Binding<Bool> = .constant(false)
|
||||||
revealed: Binding<Bool>,
|
|
||||||
allowMenu: Binding<Bool> = .constant(false),
|
|
||||||
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
|
|
||||||
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
|
|
||||||
playbackTime: Binding<TimeInterval?> = .constant(nil)
|
|
||||||
) {
|
) {
|
||||||
self.chat = chat
|
self.chat = chat
|
||||||
self.chatItem = chatItem
|
self.chatItem = chatItem
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
_scrollProxy = .init(initialValue: scrollProxy)
|
|
||||||
_revealed = revealed
|
|
||||||
_allowMenu = allowMenu
|
_allowMenu = allowMenu
|
||||||
_audioPlayer = audioPlayer
|
|
||||||
_playbackState = playbackState
|
|
||||||
_playbackTime = playbackTime
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let ci = chatItem
|
let ci = chatItem
|
||||||
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
|
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
|
||||||
MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
|
MarkedDeletedItemView(chat: chat, chatItem: chatItem)
|
||||||
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
|
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
|
||||||
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||||
EmojiItemView(chat: chat, chatItem: ci)
|
EmojiItemView(chat: chat, chatItem: ci)
|
||||||
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
||||||
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
|
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
|
||||||
} else if ci.content.msgContent == nil {
|
} else if ci.content.msgContent == nil {
|
||||||
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||||
} else {
|
} else {
|
||||||
framedItemView()
|
framedItemView()
|
||||||
}
|
}
|
||||||
@@ -62,15 +71,40 @@ struct ChatItemView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func framedItemView() -> some View {
|
private func framedItemView() -> some View {
|
||||||
FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
let preview = chatItem.content.msgContent
|
||||||
|
.flatMap {
|
||||||
|
switch $0 {
|
||||||
|
case let .image(_, image): image
|
||||||
|
case let .video(_, image, _): image
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flatMap { imageFromBase64($0) }
|
||||||
|
let adjustedMaxWidth = {
|
||||||
|
if let preview, preview.size.width <= preview.size.height {
|
||||||
|
maxWidth * 0.75
|
||||||
|
} else {
|
||||||
|
maxWidth
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return FramedItemView(
|
||||||
|
chat: chat,
|
||||||
|
chatItem: chatItem,
|
||||||
|
preview: preview,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
imgWidth: adjustedMaxWidth,
|
||||||
|
videoWidth: adjustedMaxWidth,
|
||||||
|
allowMenu: $allowMenu
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChatItemContentView<Content: View>: View {
|
struct ChatItemContentView<Content: View>: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.revealed) var revealed: Bool
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@Binding var revealed: Bool
|
|
||||||
var msgContentView: () -> Content
|
var msgContentView: () -> Content
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
|
|
||||||
@@ -97,14 +131,14 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
case .sndGroupEvent: eventItemView()
|
case .sndGroupEvent: eventItemView()
|
||||||
case .rcvConnEvent: eventItemView()
|
case .rcvConnEvent: eventItemView()
|
||||||
case .sndConnEvent: eventItemView()
|
case .sndConnEvent: eventItemView()
|
||||||
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary))
|
||||||
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary))
|
||||||
case let .rcvChatPreference(feature, allowed, param):
|
case let .rcvChatPreference(feature, allowed, param):
|
||||||
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
||||||
case let .sndChatPreference(feature, _, _):
|
case let .sndChatPreference(feature, _, _):
|
||||||
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
|
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
|
||||||
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor)
|
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
|
||||||
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor)
|
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
|
||||||
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||||
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||||
case .sndModerated: deletedItemView()
|
case .sndModerated: deletedItemView()
|
||||||
@@ -131,25 +165,25 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func eventItemView() -> some View {
|
private func eventItemView() -> some View {
|
||||||
return CIEventView(eventText: eventItemViewText())
|
CIEventView(eventText: eventItemViewText(theme.colors.secondary))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func eventItemViewText() -> Text {
|
private func eventItemViewText(_ secondaryColor: Color) -> Text {
|
||||||
if !revealed, let t = mergedGroupEventText {
|
if !revealed, let t = mergedGroupEventText {
|
||||||
return chatEventText(t + Text(" ") + chatItem.timestampText)
|
return chatEventText(t + Text(" ") + chatItem.timestampText, secondaryColor)
|
||||||
} else if let member = chatItem.memberDisplayName {
|
} else if let member = chatItem.memberDisplayName {
|
||||||
return Text(member + " ")
|
return Text(member + " ")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
+ chatEventText(chatItem)
|
+ chatEventText(chatItem, secondaryColor)
|
||||||
} else {
|
} else {
|
||||||
return chatEventText(chatItem)
|
return chatEventText(chatItem, secondaryColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
|
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
|
||||||
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
|
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mergedGroupEventText: Text? {
|
private var mergedGroupEventText: Text? {
|
||||||
@@ -179,7 +213,7 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
info.pqEnabled
|
info.pqEnabled
|
||||||
? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
: e2eeInfoNoPQText()
|
: e2eeInfoNoPQText()
|
||||||
}
|
}
|
||||||
@@ -187,39 +221,40 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
private func e2eeInfoNoPQText() -> Text {
|
private func e2eeInfoNoPQText() -> Text {
|
||||||
Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatEventText(_ text: Text) -> Text {
|
func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text {
|
||||||
text
|
text
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
|
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text {
|
||||||
chatEventText(Text(eventText) + Text(" ") + ts)
|
chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatEventText(_ ci: ChatItem) -> Text {
|
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
|
||||||
chatEventText("\(ci.content.text)", ci.timestampText)
|
chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChatItemView_Previews: PreviewProvider {
|
struct ChatItemView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group{
|
Group{
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
|
||||||
}
|
}
|
||||||
|
.environment(\.revealed, false)
|
||||||
.previewLayout(.fixed(width: 360, height: 70))
|
.previewLayout(.fixed(width: 360, height: 70))
|
||||||
.environmentObject(Chat.sampleData)
|
.environmentObject(Chat.sampleData)
|
||||||
}
|
}
|
||||||
@@ -237,8 +272,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||||||
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
|
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
|
||||||
quotedItem: nil,
|
quotedItem: nil,
|
||||||
file: nil
|
file: nil
|
||||||
),
|
)
|
||||||
revealed: Binding.constant(true)
|
|
||||||
)
|
)
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
chat: Chat.sampleData,
|
chat: Chat.sampleData,
|
||||||
@@ -248,8 +282,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||||||
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
|
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
|
||||||
quotedItem: nil,
|
quotedItem: nil,
|
||||||
file: nil
|
file: nil
|
||||||
),
|
)
|
||||||
revealed: Binding.constant(true)
|
|
||||||
)
|
)
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
chat: Chat.sampleData,
|
chat: Chat.sampleData,
|
||||||
@@ -259,8 +292,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||||||
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
|
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
|
||||||
quotedItem: nil,
|
quotedItem: nil,
|
||||||
file: nil
|
file: nil
|
||||||
),
|
)
|
||||||
revealed: Binding.constant(true)
|
|
||||||
)
|
)
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
chat: Chat.sampleData,
|
chat: Chat.sampleData,
|
||||||
@@ -270,8 +302,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||||||
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
|
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
|
||||||
quotedItem: nil,
|
quotedItem: nil,
|
||||||
file: nil
|
file: nil
|
||||||
),
|
)
|
||||||
revealed: Binding.constant(true)
|
|
||||||
)
|
)
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
chat: Chat.sampleData,
|
chat: Chat.sampleData,
|
||||||
@@ -281,10 +312,10 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
|||||||
content: ciFeatureContent,
|
content: ciFeatureContent,
|
||||||
quotedItem: nil,
|
quotedItem: nil,
|
||||||
file: nil
|
file: nil
|
||||||
),
|
)
|
||||||
revealed: Binding.constant(true)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.environment(\.revealed, true)
|
||||||
.previewLayout(.fixed(width: 360, height: 70))
|
.previewLayout(.fixed(width: 360, height: 70))
|
||||||
.environmentObject(Chat.sampleData)
|
.environmentObject(Chat.sampleData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ComposeFileView: View {
|
struct ComposeFileView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let fileName: String
|
let fileName: String
|
||||||
let cancelFile: (() -> Void)
|
let cancelFile: (() -> Void)
|
||||||
let cancelEnabled: Bool
|
let cancelEnabled: Bool
|
||||||
@@ -32,9 +32,8 @@ struct ComposeFileView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.frame(height: 50)
|
.frame(height: 54)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct ComposeImageView: View {
|
struct ComposeImageView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let images: [String]
|
let images: [String]
|
||||||
let cancelImage: (() -> Void)
|
let cancelImage: (() -> Void)
|
||||||
let cancelEnabled: Bool
|
let cancelEnabled: Bool
|
||||||
@@ -18,10 +18,7 @@ struct ComposeImageView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
HStack(alignment: .center, spacing: 8) {
|
||||||
let imgs: [UIImage] = images.compactMap { image in
|
let imgs: [UIImage] = images.compactMap { image in
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(image)) {
|
imageFromBase64(image)
|
||||||
return UIImage(data: data)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
if imgs.count == 0 {
|
if imgs.count == 0 {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -48,9 +45,9 @@ struct ComposeImageView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,37 +10,8 @@ import SwiftUI
|
|||||||
import LinkPresentation
|
import LinkPresentation
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
|
|
||||||
logger.debug("getLinkMetadata: fetching URL preview")
|
|
||||||
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
|
|
||||||
if let e = error {
|
|
||||||
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
|
|
||||||
}
|
|
||||||
if let metadata = metadata,
|
|
||||||
let imageProvider = metadata.imageProvider,
|
|
||||||
imageProvider.canLoadObject(ofClass: UIImage.self) {
|
|
||||||
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
|
|
||||||
var linkPreview: LinkPreview? = nil
|
|
||||||
if let error = error {
|
|
||||||
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
|
|
||||||
} else {
|
|
||||||
if let image = object as? UIImage,
|
|
||||||
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
|
|
||||||
let title = metadata.title,
|
|
||||||
let uri = metadata.originalURL {
|
|
||||||
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cb(linkPreview)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cb(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ComposeLinkView: View {
|
struct ComposeLinkView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let linkPreview: LinkPreview?
|
let linkPreview: LinkPreview?
|
||||||
var cancelPreview: (() -> Void)? = nil
|
var cancelPreview: (() -> Void)? = nil
|
||||||
let cancelEnabled: Bool
|
let cancelEnabled: Bool
|
||||||
@@ -62,15 +33,14 @@ struct ComposeLinkView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
|
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
HStack(alignment: .center, spacing: 8) {
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
|
if let uiImage = imageFromBase64(linkPreview.image) {
|
||||||
let uiImage = UIImage(data: data) {
|
|
||||||
Image(uiImage: uiImage)
|
Image(uiImage: uiImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
@@ -82,10 +52,10 @@ struct ComposeLinkView: View {
|
|||||||
Text(linkPreview.uri.absoluteString)
|
Text(linkPreview.uri.absoluteString)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
.frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
|
.frame(maxWidth: .infinity, minHeight: 60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ enum ComposeContextItem {
|
|||||||
case noContextItem
|
case noContextItem
|
||||||
case quotedItem(chatItem: ChatItem)
|
case quotedItem(chatItem: ChatItem)
|
||||||
case editingItem(chatItem: ChatItem)
|
case editingItem(chatItem: ChatItem)
|
||||||
case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo)
|
case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VoiceMessageRecordingState {
|
enum VoiceMessageRecordingState {
|
||||||
@@ -73,10 +73,10 @@ struct ComposeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) {
|
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
|
||||||
self.message = ""
|
self.message = ""
|
||||||
self.preview = .noPreview
|
self.preview = .noPreview
|
||||||
self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo)
|
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
|
||||||
self.voiceMessageRecordingState = .noRecording
|
self.voiceMessageRecordingState = .noRecording
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ struct ComposeState {
|
|||||||
|
|
||||||
var forwarding: Bool {
|
var forwarding: Bool {
|
||||||
switch contextItem {
|
switch contextItem {
|
||||||
case .forwardingItem: return true
|
case .forwardingItems: return true
|
||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +167,13 @@ struct ComposeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var manyMediaPreviews: Bool {
|
||||||
|
switch preview {
|
||||||
|
case let .mediaPreviews(mediaPreviews): return mediaPreviews.count > 1
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var attachmentDisabled: Bool {
|
var attachmentDisabled: Bool {
|
||||||
if editing || forwarding || liveMessage != nil || inProgress { return true }
|
if editing || forwarding || liveMessage != nil || inProgress { return true }
|
||||||
switch preview {
|
switch preview {
|
||||||
@@ -254,6 +261,7 @@ enum UploadContent: Equatable {
|
|||||||
|
|
||||||
struct ComposeView: View {
|
struct ComposeView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
@Binding var keyboardVisible: Bool
|
@Binding var keyboardVisible: Bool
|
||||||
@@ -280,21 +288,28 @@ struct ComposeView: View {
|
|||||||
@State private var stopPlayback: Bool = false
|
@State private var stopPlayback: Bool = false
|
||||||
|
|
||||||
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||||
|
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
Divider()
|
||||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||||
ContextInvitingContactMemberView()
|
ContextInvitingContactMemberView()
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
|
// preference checks should match checks in forwarding list
|
||||||
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||||
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
|
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
|
||||||
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
|
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
|
||||||
if simplexLinkProhibited {
|
if simplexLinkProhibited {
|
||||||
msgNotAllowedView("SimpleX links not allowed", icon: "link")
|
msgNotAllowedView("SimpleX links not allowed", icon: "link")
|
||||||
|
Divider()
|
||||||
} else if fileProhibited {
|
} else if fileProhibited {
|
||||||
msgNotAllowedView("Files and media not allowed", icon: "doc")
|
msgNotAllowedView("Files and media not allowed", icon: "doc")
|
||||||
|
Divider()
|
||||||
} else if voiceProhibited {
|
} else if voiceProhibited {
|
||||||
msgNotAllowedView("Voice messages not allowed", icon: "mic")
|
msgNotAllowedView("Voice messages not allowed", icon: "mic")
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
contextItemView()
|
contextItemView()
|
||||||
switch (composeState.editing, composeState.preview) {
|
switch (composeState.editing, composeState.preview) {
|
||||||
@@ -313,6 +328,7 @@ struct ComposeView: View {
|
|||||||
.frame(width: 25, height: 25)
|
.frame(width: 25, height: 25)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
|
.tint(theme.colors.primary)
|
||||||
if case let .group(g) = chat.chatInfo,
|
if case let .group(g) = chat.chatInfo,
|
||||||
!g.fullGroupPreferences.files.on(for: g.membership) {
|
!g.fullGroupPreferences.files.on(for: g.membership) {
|
||||||
b.disabled(true).onTapGesture {
|
b.disabled(true).onTapGesture {
|
||||||
@@ -353,16 +369,15 @@ struct ComposeView: View {
|
|||||||
keyboardVisible: $keyboardVisible,
|
keyboardVisible: $keyboardVisible,
|
||||||
sendButtonColor: chat.chatInfo.incognito
|
sendButtonColor: chat.chatInfo.incognito
|
||||||
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
||||||
: .accentColor
|
: theme.colors.primary
|
||||||
)
|
)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(.background)
|
|
||||||
.disabled(!chat.userCanSend)
|
.disabled(!chat.userCanSend)
|
||||||
|
|
||||||
if chat.userIsObserver {
|
if chat.userIsObserver {
|
||||||
Text("you are observer")
|
Text("you are observer")
|
||||||
.italic()
|
.italic()
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
@@ -374,6 +389,11 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background {
|
||||||
|
Color.clear
|
||||||
|
.overlay(ToolbarMaterial.material(toolbarMaterial))
|
||||||
|
.ignoresSafeArea(.all, edges: .bottom)
|
||||||
|
}
|
||||||
.onChange(of: composeState.message) { msg in
|
.onChange(of: composeState.message) { msg in
|
||||||
if composeState.linkPreviewAllowed {
|
if composeState.linkPreviewAllowed {
|
||||||
if msg.count > 0 {
|
if msg.count > 0 {
|
||||||
@@ -439,7 +459,7 @@ struct ComposeView: View {
|
|||||||
Task {
|
Task {
|
||||||
var media: [(String, UploadContent)] = []
|
var media: [(String, UploadContent)] = []
|
||||||
for content in selected {
|
for content in selected {
|
||||||
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||||
media.append((img, content))
|
media.append((img, content))
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
|
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
|
||||||
@@ -531,7 +551,7 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func addMediaContent(_ content: UploadContent) async {
|
private func addMediaContent(_ content: UploadContent) async {
|
||||||
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||||
var newMedia: [(String, UploadContent?)] = []
|
var newMedia: [(String, UploadContent?)] = []
|
||||||
if case var .mediaPreviews(media) = composeState.preview {
|
if case var .mediaPreviews(media) = composeState.preview {
|
||||||
media.append((img, content))
|
media.append((img, content))
|
||||||
@@ -622,6 +642,7 @@ struct ComposeView: View {
|
|||||||
cancelPreview: cancelLinkPreview,
|
cancelPreview: cancelLinkPreview,
|
||||||
cancelEnabled: !composeState.inProgress
|
cancelEnabled: !composeState.inProgress
|
||||||
)
|
)
|
||||||
|
Divider()
|
||||||
case let .mediaPreviews(mediaPreviews: media):
|
case let .mediaPreviews(mediaPreviews: media):
|
||||||
ComposeImageView(
|
ComposeImageView(
|
||||||
images: media.map { (img, _) in img },
|
images: media.map { (img, _) in img },
|
||||||
@@ -630,6 +651,7 @@ struct ComposeView: View {
|
|||||||
chosenMedia = []
|
chosenMedia = []
|
||||||
},
|
},
|
||||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||||
|
Divider()
|
||||||
case let .voicePreview(recordingFileName, _):
|
case let .voicePreview(recordingFileName, _):
|
||||||
ComposeVoiceView(
|
ComposeVoiceView(
|
||||||
recordingFileName: recordingFileName,
|
recordingFileName: recordingFileName,
|
||||||
@@ -642,6 +664,7 @@ struct ComposeView: View {
|
|||||||
cancelEnabled: !composeState.editing && !composeState.inProgress,
|
cancelEnabled: !composeState.editing && !composeState.inProgress,
|
||||||
stopPlayback: $stopPlayback
|
stopPlayback: $stopPlayback
|
||||||
)
|
)
|
||||||
|
Divider()
|
||||||
case let .filePreview(fileName, _):
|
case let .filePreview(fileName, _):
|
||||||
ComposeFileView(
|
ComposeFileView(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
@@ -649,19 +672,19 @@ struct ComposeView: View {
|
|||||||
composeState = composeState.copy(preview: .noPreview)
|
composeState = composeState.copy(preview: .noPreview)
|
||||||
},
|
},
|
||||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
|
private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: icon).foregroundColor(.secondary)
|
Image(systemName: icon).foregroundColor(theme.colors.secondary)
|
||||||
Text(reason).italic()
|
Text(reason).italic()
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(.thinMaterial)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func contextItemView() -> some View {
|
@ViewBuilder private func contextItemView() -> some View {
|
||||||
@@ -671,25 +694,27 @@ struct ComposeView: View {
|
|||||||
case let .quotedItem(chatItem: quotedItem):
|
case let .quotedItem(chatItem: quotedItem):
|
||||||
ContextItemView(
|
ContextItemView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
contextItem: quotedItem,
|
contextItems: [quotedItem],
|
||||||
contextIcon: "arrowshape.turn.up.left",
|
contextIcon: "arrowshape.turn.up.left",
|
||||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||||
)
|
)
|
||||||
|
Divider()
|
||||||
case let .editingItem(chatItem: editingItem):
|
case let .editingItem(chatItem: editingItem):
|
||||||
ContextItemView(
|
ContextItemView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
contextItem: editingItem,
|
contextItems: [editingItem],
|
||||||
contextIcon: "pencil",
|
contextIcon: "pencil",
|
||||||
cancelContextItem: { clearState() }
|
cancelContextItem: { clearState() }
|
||||||
)
|
)
|
||||||
case let .forwardingItem(chatItem: forwardedItem, _):
|
Divider()
|
||||||
|
case let .forwardingItems(chatItems, _):
|
||||||
ContextItemView(
|
ContextItemView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
contextItem: forwardedItem,
|
contextItems: chatItems,
|
||||||
contextIcon: "arrowshape.turn.up.forward",
|
contextIcon: "arrowshape.turn.up.forward",
|
||||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
|
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||||
showSender: false
|
|
||||||
)
|
)
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,10 +736,11 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||||
await sendMemberContactInvitation()
|
await sendMemberContactInvitation()
|
||||||
} else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem {
|
} else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem {
|
||||||
sent = await forwardItem(ci, fromChatInfo, ttl)
|
// Composed text is send as a reply to the last forwarded item
|
||||||
|
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
|
||||||
if !composeState.message.isEmpty {
|
if !composeState.message.isEmpty {
|
||||||
sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
|
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
|
||||||
}
|
}
|
||||||
} else if case let .editingItem(ci) = composeState.contextItem {
|
} else if case let .editingItem(ci) = composeState.contextItem {
|
||||||
sent = await updateMessage(ci, live: live)
|
sent = await updateMessage(ci, live: live)
|
||||||
@@ -731,26 +757,28 @@ struct ComposeView: View {
|
|||||||
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
|
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
|
||||||
case .linkPreview:
|
case .linkPreview:
|
||||||
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
|
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
|
||||||
case let .mediaPreviews(mediaPreviews: media):
|
case let .mediaPreviews(media):
|
||||||
let last = media.count - 1
|
let last = media.count - 1
|
||||||
|
var msgs: [ComposedMessage] = []
|
||||||
if last >= 0 {
|
if last >= 0 {
|
||||||
for i in 0..<last {
|
for i in 0..<last {
|
||||||
if case (_, .video(_, _, _)) = media[i] {
|
if i > 0 {
|
||||||
sent = await sendVideo(media[i], ttl: ttl)
|
// Sleep to allow `progressByTimeout` update be rendered
|
||||||
} else {
|
try? await Task.sleep(nanoseconds: 100_000000)
|
||||||
sent = await sendImage(media[i], ttl: ttl)
|
}
|
||||||
|
if let (fileSource, msgContent) = mediaContent(media[i], text: "") {
|
||||||
|
msgs.append(ComposedMessage(fileSource: fileSource, msgContent: msgContent))
|
||||||
}
|
}
|
||||||
_ = try? await Task.sleep(nanoseconds: 100_000000)
|
|
||||||
}
|
}
|
||||||
if case (_, .video(_, _, _)) = media[last] {
|
if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) {
|
||||||
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
|
msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent))
|
||||||
} else {
|
|
||||||
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sent == nil {
|
if msgs.isEmpty {
|
||||||
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
|
msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))]
|
||||||
}
|
}
|
||||||
|
sent = await send(msgs, live: live, ttl: ttl).last
|
||||||
|
|
||||||
case let .voicePreview(recordingFileName, duration):
|
case let .voicePreview(recordingFileName, duration):
|
||||||
stopPlayback.toggle()
|
stopPlayback.toggle()
|
||||||
let file = voiceCryptoFile(recordingFileName)
|
let file = voiceCryptoFile(recordingFileName)
|
||||||
@@ -772,6 +800,20 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
return sent
|
return sent
|
||||||
|
|
||||||
|
func mediaContent(_ media: (String, UploadContent?), text: String) -> (CryptoFile?, MsgContent)? {
|
||||||
|
let (previewImage, uploadContent) = media
|
||||||
|
return switch uploadContent {
|
||||||
|
case let .simpleImage(image):
|
||||||
|
(saveImage(image), .image(text: text, image: previewImage))
|
||||||
|
case let .animatedImage(image):
|
||||||
|
(saveAnimImage(image), .image(text: text, image: previewImage))
|
||||||
|
case let .video(_, url, duration):
|
||||||
|
(moveTempFileFromURL(url), .video(text: text, image: previewImage, duration: duration))
|
||||||
|
case .none:
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sending() async {
|
func sending() async {
|
||||||
await MainActor.run { composeState.inProgress = true }
|
await MainActor.run { composeState.inProgress = true }
|
||||||
}
|
}
|
||||||
@@ -835,22 +877,6 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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, ttl: ttl)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = moveTempFileFromURL(url) {
|
|
||||||
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
|
func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
|
||||||
if !privacyEncryptLocalFilesGroupDefault.get() {
|
if !privacyEncryptLocalFilesGroupDefault.get() {
|
||||||
return CryptoFile.plain(fileName)
|
return CryptoFile.plain(fileName)
|
||||||
@@ -867,44 +893,66 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||||
if let chatItem = chat.chatInfo.chatType == .local
|
await send(
|
||||||
? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc)
|
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)],
|
||||||
: await apiSendMessage(
|
live: live,
|
||||||
|
ttl: ttl
|
||||||
|
).first
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] {
|
||||||
|
if let chatItems = chat.chatInfo.chatType == .local
|
||||||
|
? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs)
|
||||||
|
: await apiSendMessages(
|
||||||
type: chat.chatInfo.chatType,
|
type: chat.chatInfo.chatType,
|
||||||
id: chat.chatInfo.apiId,
|
id: chat.chatInfo.apiId,
|
||||||
file: file,
|
|
||||||
quotedItemId: quoted,
|
|
||||||
msg: mc,
|
|
||||||
live: live,
|
live: live,
|
||||||
ttl: ttl
|
ttl: ttl,
|
||||||
|
composedMessages: msgs
|
||||||
) {
|
) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
chatModel.removeLiveDummy(animated: false)
|
chatModel.removeLiveDummy(animated: false)
|
||||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
for chatItem in chatItems {
|
||||||
|
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return chatItem
|
return chatItems
|
||||||
}
|
}
|
||||||
if let file = file {
|
for msg in msgs {
|
||||||
removeFile(file.filePath)
|
if let file = msg.fileSource {
|
||||||
|
removeFile(file.filePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
|
func forwardItems(_ forwardedItems: [ChatItem], _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> [ChatItem] {
|
||||||
if let chatItem = await apiForwardChatItem(
|
if let chatItems = await apiForwardChatItems(
|
||||||
toChatType: chat.chatInfo.chatType,
|
toChatType: chat.chatInfo.chatType,
|
||||||
toChatId: chat.chatInfo.apiId,
|
toChatId: chat.chatInfo.apiId,
|
||||||
fromChatType: fromChatInfo.chatType,
|
fromChatType: fromChatInfo.chatType,
|
||||||
fromChatId: fromChatInfo.apiId,
|
fromChatId: fromChatInfo.apiId,
|
||||||
itemId: forwardedItem.id,
|
itemIds: forwardedItems.map { $0.id },
|
||||||
ttl: ttl
|
ttl: ttl
|
||||||
) {
|
) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
for chatItem in chatItems {
|
||||||
|
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||||
|
}
|
||||||
|
if forwardedItems.count != chatItems.count {
|
||||||
|
showAlert(
|
||||||
|
String.localizedStringWithFormat(
|
||||||
|
NSLocalizedString("%d messages not forwarded", comment: "alert title"),
|
||||||
|
forwardedItems.count - chatItems.count
|
||||||
|
),
|
||||||
|
message: NSLocalizedString("Messages were deleted after you selected them.", comment: "alert message")
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return chatItem
|
return chatItems
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkLinkPreview() -> MsgContent {
|
func checkLinkPreview() -> MsgContent {
|
||||||
@@ -921,14 +969,6 @@ struct ComposeView: View {
|
|||||||
return .text(msgText)
|
return .text(msgText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveAnyImage(_ img: UploadContent) -> CryptoFile? {
|
|
||||||
switch img {
|
|
||||||
case let .simpleImage(image): return saveImage(image)
|
|
||||||
case let .animatedImage(image): return saveAnimImage(image)
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startVoiceMessageRecording() async {
|
private func startVoiceMessageRecording() async {
|
||||||
@@ -1065,7 +1105,7 @@ struct ComposeView: View {
|
|||||||
} else {
|
} else {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
let simplexLink = parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
|
let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
|
||||||
return (url, simplexLink)
|
return (url, simplexLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func voiceMessageTime_(_ time: TimeInterval?) -> String {
|
|||||||
|
|
||||||
struct ComposeVoiceView: View {
|
struct ComposeVoiceView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var recordingFileName: String
|
var recordingFileName: String
|
||||||
@Binding var recordingTime: TimeInterval?
|
@Binding var recordingTime: TimeInterval?
|
||||||
@Binding var recordingState: VoiceMessageRecordingState
|
@Binding var recordingState: VoiceMessageRecordingState
|
||||||
@@ -50,9 +50,9 @@ struct ComposeVoiceView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.frame(height: ComposeVoiceView.previewHeight)
|
.frame(height: ComposeVoiceView.previewHeight)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func recordingMode() -> some View {
|
private func recordingMode() -> some View {
|
||||||
@@ -80,7 +80,7 @@ struct ComposeVoiceView: View {
|
|||||||
Button {
|
Button {
|
||||||
startPlayback()
|
startPlayback()
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
Text(voiceMessageTime_(recordingTime))
|
Text(voiceMessageTime_(recordingTime))
|
||||||
case .playing:
|
case .playing:
|
||||||
@@ -88,7 +88,7 @@ struct ComposeVoiceView: View {
|
|||||||
audioPlayer?.pause()
|
audioPlayer?.pause()
|
||||||
playbackState = .paused
|
playbackState = .paused
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("pause.fill")
|
playPauseIcon("pause.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
Text(voiceMessageTime_(playbackTime))
|
Text(voiceMessageTime_(playbackTime))
|
||||||
case .paused:
|
case .paused:
|
||||||
@@ -96,7 +96,7 @@ struct ComposeVoiceView: View {
|
|||||||
audioPlayer?.play()
|
audioPlayer?.play()
|
||||||
playbackState = .playing
|
playbackState = .playing
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
Text(voiceMessageTime_(playbackTime))
|
Text(voiceMessageTime_(playbackTime))
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ struct ComposeVoiceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
private func playPauseIcon(_ image: String, _ color: Color) -> some View {
|
||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
@@ -147,9 +147,11 @@ struct ComposeVoiceView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "multiply")
|
Image(systemName: "multiply")
|
||||||
}
|
}
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SliderBar: View {
|
struct SliderBar: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var length: TimeInterval
|
var length: TimeInterval
|
||||||
@Binding var progress: TimeInterval?
|
@Binding var progress: TimeInterval?
|
||||||
var seek: (TimeInterval) -> Void
|
var seek: (TimeInterval) -> Void
|
||||||
@@ -158,10 +160,12 @@ struct ComposeVoiceView: View {
|
|||||||
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
|
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 4)
|
.frame(height: 4)
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ProgressBar: View {
|
private struct ProgressBar: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var length: TimeInterval
|
var length: TimeInterval
|
||||||
@Binding var progress: TimeInterval?
|
@Binding var progress: TimeInterval?
|
||||||
|
|
||||||
@@ -169,7 +173,7 @@ struct ComposeVoiceView: View {
|
|||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack {
|
ZStack {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.accentColor)
|
.fill(theme.colors.primary)
|
||||||
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
|
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
|
||||||
.animation(.linear, value: progress)
|
.animation(.linear, value: progress)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContextInvitingContactMemberView: View {
|
struct ContextInvitingContactMemberView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "message")
|
Image(systemName: "message")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
Text("Send direct message to connect")
|
Text("Send direct message to connect")
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(.thinMaterial)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct ContextItemView: View {
|
struct ContextItemView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
let contextItem: ChatItem
|
let contextItems: [ChatItem]
|
||||||
let contextIcon: String
|
let contextIcon: String
|
||||||
let cancelContextItem: () -> Void
|
let cancelContextItem: () -> Void
|
||||||
var showSender: Bool = true
|
var showSender: Bool = true
|
||||||
@@ -23,14 +23,23 @@ struct ContextItemView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
if showSender, let sender = contextItem.memberDisplayName {
|
if let singleItem = contextItems.first, contextItems.count == 1 {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
if showSender, let sender = singleItem.memberDisplayName {
|
||||||
Text(sender).font(.caption).foregroundColor(.secondary)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
msgContentView(lines: 2)
|
Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
|
||||||
}
|
msgContentView(lines: 2, contextItem: singleItem)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msgContentView(lines: 3, contextItem: singleItem)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
msgContentView(lines: 3)
|
Text(
|
||||||
|
chat.chatInfo.chatType == .local
|
||||||
|
? "Saving \(contextItems.count) messages"
|
||||||
|
: "Forwarding \(contextItems.count) messages"
|
||||||
|
)
|
||||||
|
.italic()
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
@@ -40,28 +49,37 @@ struct ContextItemView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "multiply")
|
Image(systemName: "multiply")
|
||||||
}
|
}
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(chatItemFrameColor(contextItem, colorScheme))
|
.background(background)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func msgContentView(lines: Int) -> some View {
|
private var background: Color {
|
||||||
contextMsgPreview()
|
contextItems.first
|
||||||
|
.map { chatItemFrameColor($0, theme) }
|
||||||
|
?? Color(uiColor: .tertiarySystemBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func msgContentView(lines: Int, contextItem: ChatItem) -> some View {
|
||||||
|
contextMsgPreview(contextItem)
|
||||||
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
|
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
|
||||||
.lineLimit(lines)
|
.lineLimit(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func contextMsgPreview() -> Text {
|
private func contextMsgPreview(_ contextItem: ChatItem) -> Text {
|
||||||
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false)
|
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||||
|
|
||||||
func attachment() -> Text {
|
func attachment() -> Text {
|
||||||
|
let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
|
||||||
|
FileManager.default.fileExists(atPath: getAppFilePath(fileSource.filePath).path)
|
||||||
|
} else { false }
|
||||||
switch contextItem.content.msgContent {
|
switch contextItem.content.msgContent {
|
||||||
case .file: return image("doc.fill")
|
case .file: return isFileLoaded ? image("doc.fill") : Text("")
|
||||||
case .image: return image("photo")
|
case .image: return image("photo")
|
||||||
case .voice: return image("play.fill")
|
case .voice: return isFileLoaded ? image("play.fill") : Text("")
|
||||||
default: return Text("")
|
default: return Text("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +93,6 @@ struct ContextItemView: View {
|
|||||||
struct ContextItemView_Previews: PreviewProvider {
|
struct ContextItemView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||||
return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
|
return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let field = CustomUITextField(height: _height)
|
let field = CustomUITextField(height: _height)
|
||||||
|
field.backgroundColor = .clear
|
||||||
field.text = text
|
field.text = text
|
||||||
field.textAlignment = alignment(text)
|
field.textAlignment = alignment(text)
|
||||||
field.autocapitalizationType = .sentences
|
field.autocapitalizationType = .sentences
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000
|
|||||||
|
|
||||||
struct SendMessageView: View {
|
struct SendMessageView: View {
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var sendMessage: (Int?) -> Void
|
var sendMessage: (Int?) -> Void
|
||||||
var sendLiveMessage: (() async -> Void)? = nil
|
var sendLiveMessage: (() async -> Void)? = nil
|
||||||
var updateLiveMessage: (() async -> Void)? = nil
|
var updateLiveMessage: (() async -> Void)? = nil
|
||||||
@@ -43,13 +44,14 @@ struct SendMessageView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||||
HStack(alignment: .bottom) {
|
HStack(alignment: .bottom) {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
if case .voicePreview = composeState.preview {
|
if case .voicePreview = composeState.preview {
|
||||||
Text("Voice message…")
|
Text("Voice message…")
|
||||||
.font(teFont.italic())
|
.font(teFont.italic())
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -65,7 +67,6 @@ struct SendMessageView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if progressByTimeout {
|
if progressByTimeout {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.scaleEffect(1.4)
|
.scaleEffect(1.4)
|
||||||
@@ -83,10 +84,9 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.overlay(
|
.background(theme.colors.background)
|
||||||
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
.clipShape(composeShape)
|
||||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.onChange(of: composeState.message, perform: { text in updateFont(text) })
|
.onChange(of: composeState.message, perform: { text in updateFont(text) })
|
||||||
.onChange(of: composeState.inProgress) { inProgress in
|
.onChange(of: composeState.inProgress) { inProgress in
|
||||||
@@ -227,6 +227,7 @@ struct SendMessageView: View {
|
|||||||
!composeState.editing {
|
!composeState.editing {
|
||||||
if case .noContextItem = composeState.contextItem,
|
if case .noContextItem = composeState.contextItem,
|
||||||
!composeState.voicePreview,
|
!composeState.voicePreview,
|
||||||
|
!composeState.manyMediaPreviews,
|
||||||
let send = sendLiveMessage,
|
let send = sendLiveMessage,
|
||||||
let update = updateLiveMessage {
|
let update = updateLiveMessage {
|
||||||
Button {
|
Button {
|
||||||
@@ -247,6 +248,7 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct RecordVoiceMessageButton: View {
|
private struct RecordVoiceMessageButton: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var startVoiceMessageRecording: (() -> Void)?
|
var startVoiceMessageRecording: (() -> Void)?
|
||||||
var finishVoiceMessageRecording: (() -> Void)?
|
var finishVoiceMessageRecording: (() -> Void)?
|
||||||
@Binding var holdingVMR: Bool
|
@Binding var holdingVMR: Bool
|
||||||
@@ -256,7 +258,10 @@ struct SendMessageView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: {}) {
|
Button(action: {}) {
|
||||||
Image(systemName: "mic.fill")
|
Image(systemName: "mic.fill")
|
||||||
.foregroundColor(.accentColor)
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.disabled(disabled)
|
.disabled(disabled)
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -309,7 +314,10 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "mic")
|
Image(systemName: "mic")
|
||||||
.foregroundColor(.secondary)
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.disabled(composeState.inProgress)
|
.disabled(composeState.inProgress)
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -323,7 +331,7 @@ struct SendMessageView: View {
|
|||||||
Image(systemName: "multiply")
|
Image(systemName: "multiply")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.frame(width: 15, height: 15)
|
.frame(width: 15, height: 15)
|
||||||
}
|
}
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -340,7 +348,7 @@ struct SendMessageView: View {
|
|||||||
Image(systemName: "bolt.fill")
|
Image(systemName: "bolt.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
}
|
}
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -383,7 +391,7 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
||||||
while composeState.liveMessage != nil {
|
while await composeState.liveMessage != nil {
|
||||||
await update()
|
await update()
|
||||||
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
||||||
}
|
}
|
||||||
@@ -394,7 +402,7 @@ struct SendMessageView: View {
|
|||||||
private func finishVoiceMessageRecordingButton() -> some View {
|
private func finishVoiceMessageRecordingButton() -> some View {
|
||||||
Button(action: { finishVoiceMessageRecording?() }) {
|
Button(action: { finishVoiceMessageRecording?() }) {
|
||||||
Image(systemName: "stop.fill")
|
Image(systemName: "stop.fill")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.disabled(composeState.inProgress)
|
.disabled(composeState.inProgress)
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import SimpleXChat
|
|||||||
struct ContactPreferencesView: View {
|
struct ContactPreferencesView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var contact: Contact
|
@Binding var contact: Contact
|
||||||
@State var featuresAllowed: ContactFeaturesAllowed
|
@State var featuresAllowed: ContactFeaturesAllowed
|
||||||
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||||
@@ -66,8 +67,8 @@ struct ContactPreferencesView: View {
|
|||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
infoRow("Contact allows", pref.contactPreference.allow.text)
|
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||||
}
|
}
|
||||||
header: { featureHeader(feature, enabled) }
|
header: { featureHeader(feature, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
footer: { featureFooter(feature, enabled) }
|
footer: { featureFooter(feature, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func timedMessagesFeatureSection() -> some View {
|
private func timedMessagesFeatureSection() -> some View {
|
||||||
@@ -102,8 +103,8 @@ struct ContactPreferencesView: View {
|
|||||||
infoRow("Delete after", timeText(pref.contactPreference.ttl))
|
infoRow("Delete after", timeText(pref.contactPreference.ttl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
header: { featureHeader(.timedMessages, enabled) }
|
header: { featureHeader(.timedMessages, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
footer: { featureFooter(.timedMessages, enabled) }
|
footer: { featureFooter(.timedMessages, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ struct AddGroupMembersView: View {
|
|||||||
|
|
||||||
struct AddGroupMembersViewCommon: View {
|
struct AddGroupMembersViewCommon: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chat: Chat
|
var chat: Chat
|
||||||
@State var groupInfo: GroupInfo
|
@State var groupInfo: GroupInfo
|
||||||
var creatingGroup: Bool = false
|
var creatingGroup: Bool = false
|
||||||
@@ -34,7 +35,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
|
|
||||||
private enum AddGroupMembersAlert: Identifiable {
|
private enum AddGroupMembersAlert: Identifiable {
|
||||||
case prohibitedToInviteIncognito
|
case prohibitedToInviteIncognito
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -46,14 +47,13 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if creatingGroup {
|
if creatingGroup {
|
||||||
NavigationView {
|
addGroupMembersView()
|
||||||
addGroupMembersView()
|
.navigationBarBackButtonHidden()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button ("Skip") { addedMembersCb(selectedContacts) }
|
Button ("Skip") { addedMembersCb(selectedContacts) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
addGroupMembersView()
|
addGroupMembersView()
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
|
|
||||||
if (membersToAdd.isEmpty) {
|
if (membersToAdd.isEmpty) {
|
||||||
Text("No contacts to add")
|
Text("No contacts to add")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -90,21 +90,25 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) }
|
Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) }
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(count) contact(s) selected")
|
Text("\(count) contact(s) selected")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No contacts selected")
|
Text("No contacts selected")
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
searchFieldView(text: $searchText, focussed: $searchFocussed)
|
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.primary, theme.colors.secondary)
|
||||||
.padding(.leading, 2)
|
.padding(.leading, 2)
|
||||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||||
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
||||||
ForEach(members) { contact in
|
ForEach(members + [dummyContact]) { contact in
|
||||||
contactCheckView(contact)
|
if contact.contactId != dummyContact.contactId {
|
||||||
|
contactCheckView(contact)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,14 +123,23 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
|
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
|
||||||
)
|
)
|
||||||
case let .error(title, error):
|
case let .error(title, error):
|
||||||
return Alert(title: Text(title), message: Text(error))
|
return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedContacts) { _ in
|
.onChange(of: selectedContacts) { _ in
|
||||||
searchFocussed = false
|
searchFocussed = false
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolves keyboard losing focus bug in iOS16 and iOS17,
|
||||||
|
// when there are no items inside `ForEach(memebers)` loop
|
||||||
|
private let dummyContact: Contact = {
|
||||||
|
var dummy = Contact.sampleData
|
||||||
|
dummy.contactId = -1
|
||||||
|
return dummy
|
||||||
|
}()
|
||||||
|
|
||||||
private func inviteMembersButton() -> some View {
|
private func inviteMembersButton() -> some View {
|
||||||
Button {
|
Button {
|
||||||
inviteMembers()
|
inviteMembers()
|
||||||
@@ -172,14 +185,14 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
var iconColor: Color
|
var iconColor: Color
|
||||||
if prohibitedToInviteIncognito {
|
if prohibitedToInviteIncognito {
|
||||||
icon = "theatermasks.circle.fill"
|
icon = "theatermasks.circle.fill"
|
||||||
iconColor = Color(uiColor: .tertiaryLabel)
|
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
|
||||||
} else {
|
} else {
|
||||||
if checked {
|
if checked {
|
||||||
icon = "checkmark.circle.fill"
|
icon = "checkmark.circle.fill"
|
||||||
iconColor = .accentColor
|
iconColor = theme.colors.primary
|
||||||
} else {
|
} else {
|
||||||
icon = "circle"
|
icon = "circle"
|
||||||
iconColor = Color(uiColor: .tertiaryLabel)
|
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Button {
|
return Button {
|
||||||
@@ -197,7 +210,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
ProfileImage(imageStr: contact.image, size: 30)
|
ProfileImage(imageStr: contact.image, size: 30)
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
Text(ChatInfo.direct(contact: contact).chatViewName)
|
Text(ChatInfo.direct(contact: contact).chatViewName)
|
||||||
.foregroundColor(prohibitedToInviteIncognito ? .secondary : .primary)
|
.foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
@@ -207,7 +220,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding) -> some View {
|
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding, _ onBackgroundColor: Color, _ secondaryColor: Color) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -216,7 +229,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding)
|
|||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
TextField("Search", text: text)
|
TextField("Search", text: text)
|
||||||
.focused(focussed)
|
.focused(focussed)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(onBackgroundColor)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -228,7 +241,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding)
|
|||||||
focussed.wrappedValue = false
|
focussed.wrappedValue = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
|
|||||||
|
|
||||||
struct GroupChatInfoView: View {
|
struct GroupChatInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var groupInfo: GroupInfo
|
@Binding var groupInfo: GroupInfo
|
||||||
|
var onSearch: () -> Void
|
||||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||||
@State private var groupLink: String?
|
@State private var groupLink: String?
|
||||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||||
@State private var showAddMembersSheet: Bool = false
|
@State private var groupLinkNavLinkActive: Bool = false
|
||||||
|
@State private var addMembersNavLinkActive: Bool = false
|
||||||
@State private var connectionStats: ConnectionStats?
|
@State private var connectionStats: ConnectionStats?
|
||||||
@State private var connectionCode: String?
|
@State private var connectionCode: String?
|
||||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||||
@@ -39,7 +42,7 @@ struct GroupChatInfoView: View {
|
|||||||
case blockForAllAlert(mem: GroupMember)
|
case blockForAllAlert(mem: GroupMember)
|
||||||
case unblockForAllAlert(mem: GroupMember)
|
case unblockForAllAlert(mem: GroupMember)
|
||||||
case removeMemberAlert(mem: GroupMember)
|
case removeMemberAlert(mem: GroupMember)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -67,6 +70,15 @@ struct GroupChatInfoView: View {
|
|||||||
List {
|
List {
|
||||||
groupInfoHeader()
|
groupInfoHeader()
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
infoActionButtons()
|
||||||
|
.padding(.horizontal)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: infoViewActionButtonHeight)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
if groupInfo.canEdit {
|
if groupInfo.canEdit {
|
||||||
@@ -81,13 +93,19 @@ struct GroupChatInfoView: View {
|
|||||||
} else {
|
} else {
|
||||||
sendReceiptsOptionDisabled()
|
sendReceiptsOptionDisabled()
|
||||||
}
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ChatWallpaperEditorSheet(chat: chat)
|
||||||
|
} label: {
|
||||||
|
Label("Chat theme", systemImage: "photo")
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("")
|
Text("")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Only group owners can change group preferences.")
|
Text("Only group owners can change group preferences.")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("\(members.count + 1) members") {
|
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||||
if groupInfo.canAddMembers {
|
if groupInfo.canAddMembers {
|
||||||
groupLinkButton()
|
groupLinkButton()
|
||||||
if (chat.chatInfo.incognito) {
|
if (chat.chatInfo.incognito) {
|
||||||
@@ -99,7 +117,7 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if members.count > 8 {
|
if members.count > 8 {
|
||||||
searchFieldView(text: $searchText, focussed: $searchFocussed)
|
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||||
@@ -129,12 +147,13 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if developerTools {
|
if developerTools {
|
||||||
Section(header: Text("For console")) {
|
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
@@ -150,7 +169,7 @@ struct GroupChatInfoView: View {
|
|||||||
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
|
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
|
||||||
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
|
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
|
||||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -166,7 +185,6 @@ struct GroupChatInfoView: View {
|
|||||||
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.keyboardPadding()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func groupInfoHeader() -> some View {
|
private func groupInfoHeader() -> some View {
|
||||||
@@ -190,44 +208,116 @@ struct GroupChatInfoView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func infoActionButtons() -> some View {
|
||||||
|
GeometryReader { g in
|
||||||
|
let buttonWidth = g.size.width / 4
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
searchButton(width: buttonWidth)
|
||||||
|
if groupInfo.canAddMembers {
|
||||||
|
addMembersActionButton(width: buttonWidth)
|
||||||
|
}
|
||||||
|
muteButton(width: buttonWidth)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchButton(width: CGFloat) -> some View {
|
||||||
|
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
|
||||||
|
dismiss()
|
||||||
|
onSearch()
|
||||||
|
}
|
||||||
|
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View {
|
||||||
|
if chat.chatInfo.incognito {
|
||||||
|
ZStack {
|
||||||
|
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
|
||||||
|
groupLinkNavLinkActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationLink(isActive: $groupLinkNavLinkActive) {
|
||||||
|
groupLinkDestinationView()
|
||||||
|
} label: {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.frame(width: 1, height: 1)
|
||||||
|
.hidden()
|
||||||
|
}
|
||||||
|
.disabled(!groupInfo.ready)
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
|
||||||
|
addMembersNavLinkActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationLink(isActive: $addMembersNavLinkActive) {
|
||||||
|
addMembersDestinationView()
|
||||||
|
} label: {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.frame(width: 1, height: 1)
|
||||||
|
.hidden()
|
||||||
|
}
|
||||||
|
.disabled(!groupInfo.ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func muteButton(width: CGFloat) -> some View {
|
||||||
|
InfoViewButton(
|
||||||
|
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||||
|
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
|
||||||
|
width: width
|
||||||
|
) {
|
||||||
|
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||||
|
}
|
||||||
|
.disabled(!groupInfo.ready)
|
||||||
|
}
|
||||||
|
|
||||||
private func addMembersButton() -> some View {
|
private func addMembersButton() -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
addMembersDestinationView()
|
||||||
.onAppear {
|
|
||||||
searchFocussed = false
|
|
||||||
Task {
|
|
||||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
|
||||||
await MainActor.run {
|
|
||||||
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("Invite members", systemImage: "plus")
|
Label("Invite members", systemImage: "plus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func addMembersDestinationView() -> some View {
|
||||||
|
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||||
|
.onAppear {
|
||||||
|
searchFocussed = false
|
||||||
|
Task {
|
||||||
|
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||||
|
await MainActor.run {
|
||||||
|
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
||||||
|
chatModel.populateGroupMembersIndexes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct MemberRowView: View {
|
private struct MemberRowView: View {
|
||||||
var groupInfo: GroupInfo
|
var groupInfo: GroupInfo
|
||||||
@ObservedObject var groupMember: GMember
|
@ObservedObject var groupMember: GMember
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var user: Bool = false
|
var user: Bool = false
|
||||||
@Binding var alert: GroupChatInfoViewAlert?
|
@Binding var alert: GroupChatInfoViewAlert?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let member = groupMember.wrapped
|
let member = groupMember.wrapped
|
||||||
let v = HStack{
|
let v = HStack{
|
||||||
ProfileImage(imageStr: member.image, size: 38)
|
MemberProfileImage(member, size: 38)
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
// TODO server connection status
|
// TODO server connection status
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
|
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
|
||||||
(member.verified ? memberVerifiedShield + t : t)
|
(member.verified ? memberVerifiedShield + t : t)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
let s = Text(member.memberStatus.shortText)
|
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
|
||||||
(user ? Text ("you: ") + s : s)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
memberInfo(member)
|
memberInfo(member)
|
||||||
@@ -257,15 +347,25 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
|
||||||
|
if member.activeConn?.connDisabled ?? false {
|
||||||
|
return "disabled"
|
||||||
|
} else if member.activeConn?.connInactive ?? false {
|
||||||
|
return "inactive"
|
||||||
|
} else {
|
||||||
|
return member.memberStatus.shortText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
||||||
if member.blocked {
|
if member.blocked {
|
||||||
Text("blocked")
|
Text("blocked")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
let role = member.memberRole
|
let role = member.memberRole
|
||||||
if [.owner, .admin, .observer].contains(role) {
|
if [.owner, .admin, .observer].contains(role) {
|
||||||
Text(member.memberRole.text)
|
Text(member.memberRole.text)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,13 +376,13 @@ struct GroupChatInfoView: View {
|
|||||||
Button {
|
Button {
|
||||||
alert = .blockMemberAlert(mem: member)
|
alert = .blockMemberAlert(mem: member)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
|
Label("Block member", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
alert = .unblockMemberAlert(mem: member)
|
alert = .unblockMemberAlert(mem: member)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
|
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,13 +394,13 @@ struct GroupChatInfoView: View {
|
|||||||
Button {
|
Button {
|
||||||
alert = .unblockForAllAlert(mem: member)
|
alert = .unblockForAllAlert(mem: member)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
|
Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
alert = .blockForAllAlert(mem: member)
|
alert = .blockForAllAlert(mem: member)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Block for all", systemImage: "hand.raised").foregroundColor(.secondary)
|
Label("Block for all", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,6 +416,14 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var memberVerifiedShield: Text {
|
||||||
|
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||||
|
.font(.caption)
|
||||||
|
.baselineOffset(2)
|
||||||
|
.kerning(-2)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func memberInfoView(_ groupMember: GMember) -> some View {
|
private func memberInfoView(_ groupMember: GMember) -> some View {
|
||||||
@@ -325,15 +433,7 @@ struct GroupChatInfoView: View {
|
|||||||
|
|
||||||
private func groupLinkButton() -> some View {
|
private func groupLinkButton() -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
GroupLinkView(
|
groupLinkDestinationView()
|
||||||
groupId: groupInfo.groupId,
|
|
||||||
groupLink: $groupLink,
|
|
||||||
groupLinkMemberRole: $groupLinkMemberRole,
|
|
||||||
showTitle: false,
|
|
||||||
creatingGroup: false
|
|
||||||
)
|
|
||||||
.navigationBarTitle("Group link")
|
|
||||||
.navigationBarTitleDisplayMode(.large)
|
|
||||||
} label: {
|
} label: {
|
||||||
if groupLink == nil {
|
if groupLink == nil {
|
||||||
Label("Create group link", systemImage: "link.badge.plus")
|
Label("Create group link", systemImage: "link.badge.plus")
|
||||||
@@ -343,6 +443,19 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func groupLinkDestinationView() -> some View {
|
||||||
|
GroupLinkView(
|
||||||
|
groupId: groupInfo.groupId,
|
||||||
|
groupLink: $groupLink,
|
||||||
|
groupLinkMemberRole: $groupLinkMemberRole,
|
||||||
|
showTitle: false,
|
||||||
|
creatingGroup: false
|
||||||
|
)
|
||||||
|
.navigationBarTitle("Group link")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
}
|
||||||
|
|
||||||
private func editGroupButton() -> some View {
|
private func editGroupButton() -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
GroupProfileView(
|
GroupProfileView(
|
||||||
@@ -350,6 +463,7 @@ struct GroupChatInfoView: View {
|
|||||||
groupProfile: groupInfo.groupProfile
|
groupProfile: groupInfo.groupProfile
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Group profile")
|
.navigationBarTitle("Group profile")
|
||||||
|
.modifier(ThemedBackground())
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Edit group profile", systemImage: "pencil")
|
Label("Edit group profile", systemImage: "pencil")
|
||||||
@@ -364,6 +478,7 @@ struct GroupChatInfoView: View {
|
|||||||
welcomeText: groupInfo.groupProfile.description ?? ""
|
welcomeText: groupInfo.groupProfile.description ?? ""
|
||||||
)
|
)
|
||||||
.navigationTitle("Welcome message")
|
.navigationTitle("Welcome message")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
groupInfo.groupProfile.description == nil
|
groupInfo.groupProfile.description == nil
|
||||||
@@ -518,6 +633,7 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
|
|||||||
creatingGroup: creatingGroup
|
creatingGroup: creatingGroup
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Group preferences")
|
.navigationBarTitle("Group preferences")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
if creatingGroup {
|
if creatingGroup {
|
||||||
@@ -528,14 +644,6 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var memberVerifiedShield: Text {
|
|
||||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
|
||||||
.font(.caption)
|
|
||||||
.baselineOffset(2)
|
|
||||||
.kerning(-2)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cantInviteIncognitoAlert() -> Alert {
|
func cantInviteIncognitoAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Can't invite contacts!"),
|
title: Text("Can't invite contacts!"),
|
||||||
@@ -554,7 +662,8 @@ struct GroupChatInfoView_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
GroupChatInfoView(
|
GroupChatInfoView(
|
||||||
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
||||||
groupInfo: Binding.constant(GroupInfo.sampleData)
|
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||||
|
onSearch: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ struct GroupLinkView: View {
|
|||||||
|
|
||||||
private enum GroupLinkAlert: Identifiable {
|
private enum GroupLinkAlert: Identifiable {
|
||||||
case deleteLink
|
case deleteLink
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -34,14 +34,13 @@ struct GroupLinkView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if creatingGroup {
|
if creatingGroup {
|
||||||
NavigationView {
|
groupLinkView()
|
||||||
groupLinkView()
|
.navigationBarBackButtonHidden()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button ("Continue") { linkCreatedCb?() }
|
Button ("Continue") { linkCreatedCb?() }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
groupLinkView()
|
groupLinkView()
|
||||||
}
|
}
|
||||||
@@ -113,7 +112,7 @@ struct GroupLinkView: View {
|
|||||||
}, secondaryButton: .cancel()
|
}, secondaryButton: .cancel()
|
||||||
)
|
)
|
||||||
case let .error(title, error):
|
case let .error(title, error):
|
||||||
return Alert(title: Text(title), message: Text(error))
|
return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: groupLinkMemberRole) { _ in
|
.onChange(of: groupLinkMemberRole) { _ in
|
||||||
@@ -133,6 +132,7 @@ struct GroupLinkView: View {
|
|||||||
shouldCreate = false
|
shouldCreate = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createGroupLink() {
|
private func createGroupLink() {
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct GroupMemberInfoView: View {
|
struct GroupMemberInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@State var groupInfo: GroupInfo
|
@State var groupInfo: GroupInfo
|
||||||
@ObservedObject var groupMember: GMember
|
@ObservedObject var groupMember: GMember
|
||||||
var navigation: Bool = false
|
var navigation: Bool = false
|
||||||
@State private var connectionStats: ConnectionStats? = nil
|
@State private var connectionStats: ConnectionStats? = nil
|
||||||
@State private var connectionCode: String? = nil
|
@State private var connectionCode: String? = nil
|
||||||
|
@State private var connectionLoaded: Bool = false
|
||||||
@State private var newRole: GroupMemberRole = .member
|
@State private var newRole: GroupMemberRole = .member
|
||||||
@State private var alert: GroupMemberInfoViewAlert?
|
@State private var alert: GroupMemberInfoViewAlert?
|
||||||
@State private var sheet: PlanAndConnectActionSheet?
|
@State private var sheet: PlanAndConnectActionSheet?
|
||||||
@@ -35,7 +37,9 @@ struct GroupMemberInfoView: View {
|
|||||||
case abortSwitchAddressAlert
|
case abortSwitchAddressAlert
|
||||||
case syncConnectionForceAlert
|
case syncConnectionForceAlert
|
||||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case queueInfo(info: String)
|
||||||
|
case someAlert(alert: SomeAlert)
|
||||||
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -49,6 +53,8 @@ struct GroupMemberInfoView: View {
|
|||||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||||
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
||||||
|
case let .queueInfo(info): return "queueInfo \(info)"
|
||||||
|
case let .someAlert(alert): return "someAlert \(alert.id)"
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,10 +68,11 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func knownDirectChat(_ contactId: Int64) -> Chat? {
|
private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? {
|
||||||
if let chat = chatModel.getContactChat(contactId),
|
if let chat = chatModel.getContactChat(contactId),
|
||||||
chat.chatInfo.contact?.directOrUsed == true {
|
let contact = chat.chatInfo.contact,
|
||||||
return chat
|
contact.directOrUsed == true {
|
||||||
|
return (chat, contact)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -73,23 +80,25 @@ struct GroupMemberInfoView: View {
|
|||||||
|
|
||||||
private func groupMemberInfoView() -> some View {
|
private func groupMemberInfoView() -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack {
|
let member = groupMember.wrapped
|
||||||
let member = groupMember.wrapped
|
List {
|
||||||
List {
|
groupMemberInfoHeader(member)
|
||||||
groupMemberInfoHeader(member)
|
.listRowBackground(Color.clear)
|
||||||
.listRowBackground(Color.clear)
|
.listRowSeparator(.hidden)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
infoActionButtons(member)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: infoViewActionButtonHeight)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||||
|
|
||||||
|
if connectionLoaded {
|
||||||
|
|
||||||
if member.memberActive {
|
if member.memberActive {
|
||||||
Section {
|
Section {
|
||||||
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
|
|
||||||
knownDirectChatButton(chat)
|
|
||||||
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
|
||||||
if let contactId = member.memberContactId {
|
|
||||||
newDirectChatButton(contactId)
|
|
||||||
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
|
||||||
createMemberContactButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let code = connectionCode { verifyCodeButton(code) }
|
if let code = connectionCode { verifyCodeButton(code) }
|
||||||
if let connStats = connectionStats,
|
if let connStats = connectionStats,
|
||||||
connStats.ratchetSyncAllowed {
|
connStats.ratchetSyncAllowed {
|
||||||
@@ -118,12 +127,14 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Address")
|
Text("Address")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Member") {
|
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Group", groupInfo.displayName)
|
infoRow("Group", groupInfo.displayName)
|
||||||
|
|
||||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||||
@@ -136,16 +147,10 @@ struct GroupMemberInfoView: View {
|
|||||||
} else {
|
} else {
|
||||||
infoRow("Role", member.memberRole.text)
|
infoRow("Role", member.memberRole.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO invited by - need to get contact by contact id
|
|
||||||
if let conn = member.activeConn {
|
|
||||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
|
||||||
infoRow("Connection", connLevelDesc)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let connStats = connectionStats {
|
if let connStats = connectionStats {
|
||||||
Section("Servers") {
|
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||||
// TODO network connection status
|
// TODO network connection status
|
||||||
Button("Change receiving address") {
|
Button("Change receiving address") {
|
||||||
alert = .switchAddressAlert
|
alert = .switchAddressAlert
|
||||||
@@ -163,8 +168,8 @@ struct GroupMemberInfoView: View {
|
|||||||
|| connStats.ratchetSyncSendProhibited
|
|| connStats.ratchetSyncSendProhibited
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,41 +180,62 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if developerTools {
|
if developerTools {
|
||||||
Section("For console") {
|
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Local name", member.localDisplayName)
|
infoRow("Local name", member.localDisplayName)
|
||||||
infoRow("Database ID", "\(member.groupMemberId)")
|
infoRow("Database ID", "\(member.groupMemberId)")
|
||||||
|
if let conn = member.activeConn {
|
||||||
|
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||||
|
infoRow("Connection", connLevelDesc)
|
||||||
|
}
|
||||||
|
Button ("Debug delivery") {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
|
||||||
|
await MainActor.run { alert = .queueInfo(info: info) }
|
||||||
|
} catch let e {
|
||||||
|
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||||
|
let a = getErrorAlert(e, "Error")
|
||||||
|
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
}
|
||||||
.onAppear {
|
.navigationBarHidden(true)
|
||||||
if #unavailable(iOS 16) {
|
.task {
|
||||||
// this condition prevents re-setting picker
|
if #unavailable(iOS 16) {
|
||||||
if !justOpened { return }
|
// this condition prevents re-setting picker
|
||||||
|
if !justOpened { return }
|
||||||
|
}
|
||||||
|
justOpened = false
|
||||||
|
newRole = member.memberRole
|
||||||
|
do {
|
||||||
|
let (_, stats) = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||||
|
let (mem, code) = member.memberActive ? try await apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||||
|
await MainActor.run {
|
||||||
|
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||||
|
connectionStats = stats
|
||||||
|
connectionCode = code
|
||||||
|
connectionLoaded = true
|
||||||
}
|
}
|
||||||
justOpened = false
|
} catch let error {
|
||||||
DispatchQueue.main.async {
|
await MainActor.run {
|
||||||
newRole = member.memberRole
|
connectionLoaded = true
|
||||||
do {
|
|
||||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
|
||||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
|
||||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
|
||||||
connectionStats = stats
|
|
||||||
connectionCode = code
|
|
||||||
} catch let error {
|
|
||||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
.onChange(of: newRole) { newRole in
|
}
|
||||||
if newRole != member.memberRole {
|
.onChange(of: newRole) { newRole in
|
||||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
if newRole != member.memberRole {
|
||||||
}
|
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||||
}
|
|
||||||
.onChange(of: member.memberRole) { role in
|
|
||||||
newRole = role
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: member.memberRole) { role in
|
||||||
|
newRole = role
|
||||||
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.alert(item: $alert) { alertItem in
|
.alert(item: $alert) { alertItem in
|
||||||
switch(alertItem) {
|
switch(alertItem) {
|
||||||
@@ -223,7 +249,9 @@ struct GroupMemberInfoView: View {
|
|||||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||||
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .queueInfo(info): return queueInfoAlert(info)
|
||||||
|
case let .someAlert(a): return a.alert
|
||||||
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
||||||
@@ -232,6 +260,58 @@ struct GroupMemberInfoView: View {
|
|||||||
ProgressView().scaleEffect(2)
|
ProgressView().scaleEffect(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func infoActionButtons(_ member: GroupMember) -> some View {
|
||||||
|
GeometryReader { g in
|
||||||
|
let buttonWidth = g.size.width / 4
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
|
||||||
|
knownDirectChatButton(chat, width: buttonWidth)
|
||||||
|
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||||
|
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||||
|
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||||
|
if let contactId = member.memberContactId {
|
||||||
|
newDirectChatButton(contactId, width: buttonWidth)
|
||||||
|
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
||||||
|
createMemberContactButton(width: buttonWidth)
|
||||||
|
}
|
||||||
|
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
|
||||||
|
}
|
||||||
|
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
|
||||||
|
}
|
||||||
|
} else { // no known contact chat && directMessages are off
|
||||||
|
InfoViewButton(image: "message.fill", title: "message", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't message member")
|
||||||
|
}
|
||||||
|
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
|
||||||
|
}
|
||||||
|
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showSendMessageToEnableCallsAlert() {
|
||||||
|
alert = .someAlert(alert: SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Can't call member",
|
||||||
|
message: "Send message to enable calls."
|
||||||
|
),
|
||||||
|
id: "can't call member, send message"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) {
|
||||||
|
alert = .someAlert(alert: SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: title,
|
||||||
|
message: "Direct messages between members are prohibited in this group."
|
||||||
|
),
|
||||||
|
id: "can't message member, direct messages prohibited"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectViaAddressButton(_ contactLink: String) -> some View {
|
func connectViaAddressButton(_ contactLink: String) -> some View {
|
||||||
@@ -248,36 +328,32 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func knownDirectChatButton(_ chat: Chat) -> some View {
|
func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View {
|
||||||
Button {
|
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||||
dismissAllSheets(animated: true)
|
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||||
DispatchQueue.main.async {
|
|
||||||
chatModel.chatId = chat.id
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Send direct message", systemImage: "message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDirectChatButton(_ contactId: Int64) -> some View {
|
|
||||||
Button {
|
|
||||||
do {
|
|
||||||
let chat = try apiGetChat(type: .direct, id: contactId)
|
|
||||||
chatModel.addChat(chat)
|
|
||||||
dismissAllSheets(animated: true)
|
dismissAllSheets(animated: true)
|
||||||
DispatchQueue.main.async {
|
|
||||||
chatModel.chatId = chat.id
|
|
||||||
}
|
|
||||||
} catch let error {
|
|
||||||
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
Label("Send direct message", systemImage: "message")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createMemberContactButton() -> some View {
|
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
|
||||||
Button {
|
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let chat = try await apiGetChat(type: .direct, id: contactId)
|
||||||
|
chatModel.addChat(chat)
|
||||||
|
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||||
|
dismissAllSheets(animated: true)
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createMemberContactButton(width: CGFloat) -> some View {
|
||||||
|
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||||
progressIndicator = true
|
progressIndicator = true
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -285,9 +361,10 @@ struct GroupMemberInfoView: View {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
progressIndicator = false
|
progressIndicator = false
|
||||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||||
dismissAllSheets(animated: true)
|
ItemsModel.shared.loadOpenChat(memberContact.id) {
|
||||||
chatModel.chatId = memberContact.id
|
dismissAllSheets(animated: true)
|
||||||
chatModel.setContactNetworkStatus(memberContact, .connected)
|
}
|
||||||
|
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
|
||||||
}
|
}
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
||||||
@@ -298,20 +375,18 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
Label("Send direct message", systemImage: "message")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
ProfileImage(imageStr: mem.image, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
MemberProfileImage(mem, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding()
|
.padding()
|
||||||
if mem.verified {
|
if mem.verified {
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "checkmark.shield"))
|
Text(Image(systemName: "checkmark.shield"))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ Text(mem.displayName)
|
+ Text(mem.displayName)
|
||||||
@@ -359,6 +434,7 @@ struct GroupMemberInfoView: View {
|
|||||||
)
|
)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationTitle("Security code")
|
.navigationTitle("Security code")
|
||||||
|
.modifier(ThemedBackground())
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
member.verified ? "View security code" : "Verify security code",
|
member.verified ? "View security code" : "Verify security code",
|
||||||
@@ -408,7 +484,7 @@ struct GroupMemberInfoView: View {
|
|||||||
Section {
|
Section {
|
||||||
if mem.blockedByAdmin {
|
if mem.blockedByAdmin {
|
||||||
Label("Blocked by admin", systemImage: "hand.raised")
|
Label("Blocked by admin", systemImage: "hand.raised")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
} else if mem.memberSettings.showMessages {
|
} else if mem.memberSettings.showMessages {
|
||||||
blockMemberButton(mem)
|
blockMemberButton(mem)
|
||||||
} else {
|
} else {
|
||||||
@@ -566,6 +642,21 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MemberProfileImage(
|
||||||
|
_ mem: GroupMember,
|
||||||
|
size: CGFloat,
|
||||||
|
color: Color = Color(uiColor: .tertiarySystemGroupedBackground),
|
||||||
|
backgroundColor: Color? = nil
|
||||||
|
) -> some View {
|
||||||
|
ProfileImage(
|
||||||
|
imageStr: mem.image,
|
||||||
|
size: size,
|
||||||
|
color: color,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
blurred: mem.blocked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Block member?"),
|
title: Text("Block member?"),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ private let featureRoles: [(role: GroupMemberRole?, text: LocalizedStringKey)] =
|
|||||||
struct GroupPreferencesView: View {
|
struct GroupPreferencesView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var groupInfo: GroupInfo
|
@Binding var groupInfo: GroupInfo
|
||||||
@State var preferences: FullGroupPreferences
|
@State var preferences: FullGroupPreferences
|
||||||
@State var currentPreferences: FullGroupPreferences
|
@State var currentPreferences: FullGroupPreferences
|
||||||
@@ -34,8 +35,7 @@ struct GroupPreferencesView: View {
|
|||||||
featureSection(.reactions, $preferences.reactions.enable)
|
featureSection(.reactions, $preferences.reactions.enable)
|
||||||
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
|
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
|
||||||
featureSection(.files, $preferences.files.enable, $preferences.files.role)
|
featureSection(.files, $preferences.files.enable, $preferences.files.role)
|
||||||
// TODO enable simplexLinks preference in 5.8
|
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
|
||||||
// featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
|
|
||||||
featureSection(.history, $preferences.history.enable)
|
featureSection(.history, $preferences.history.enable)
|
||||||
|
|
||||||
if groupInfo.canEdit {
|
if groupInfo.canEdit {
|
||||||
@@ -74,7 +74,7 @@ struct GroupPreferencesView: View {
|
|||||||
|
|
||||||
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View {
|
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View {
|
||||||
Section {
|
Section {
|
||||||
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
|
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary
|
||||||
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
|
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
|
||||||
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
|
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
|
||||||
if groupInfo.canEdit {
|
if groupInfo.canEdit {
|
||||||
@@ -102,8 +102,6 @@ struct GroupPreferencesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
// remove in v5.8
|
|
||||||
.disabled(true)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
settingsRow(icon, color: color) {
|
settingsRow(icon, color: color) {
|
||||||
@@ -114,18 +112,19 @@ struct GroupPreferencesView: View {
|
|||||||
}
|
}
|
||||||
if enableFeature.wrappedValue == .on, let enableForRole {
|
if enableFeature.wrappedValue == .on, let enableForRole {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Enabled for").foregroundColor(.secondary)
|
Text("Enabled for").foregroundColor(theme.colors.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(
|
Text(
|
||||||
featureRoles.first(where: { fr in fr.role == enableForRole.wrappedValue })?.text
|
featureRoles.first(where: { fr in fr.role == enableForRole.wrappedValue })?.text
|
||||||
?? "all members"
|
?? "all members"
|
||||||
)
|
)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.onChange(of: enableFeature.wrappedValue) { enabled in
|
.onChange(of: enableFeature.wrappedValue) { enabled in
|
||||||
if case .off = enabled {
|
if case .off = enabled {
|
||||||
|
|||||||
@@ -110,10 +110,13 @@ struct GroupProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: chosenImage) { image in
|
.onChange(of: chosenImage) { image in
|
||||||
if let image = image {
|
Task {
|
||||||
groupProfile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
|
let resized: String? = if let image {
|
||||||
} else {
|
await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
|
||||||
groupProfile.image = nil
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
await MainActor.run { groupProfile.image = resized }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct GroupWelcomeView: View {
|
struct GroupWelcomeView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var groupInfo: GroupInfo
|
@Binding var groupInfo: GroupInfo
|
||||||
@State var groupProfile: GroupProfile
|
@State var groupProfile: GroupProfile
|
||||||
@State var welcomeText: String
|
@State var welcomeText: String
|
||||||
@@ -57,7 +58,7 @@ struct GroupWelcomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func textPreview() -> some View {
|
private func textPreview() -> some View {
|
||||||
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false)
|
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||||
.frame(minHeight: 130, alignment: .topLeading)
|
.frame(minHeight: 130, alignment: .topLeading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,7 @@ struct GroupWelcomeView: View {
|
|||||||
Group {
|
Group {
|
||||||
if welcomeText.isEmpty {
|
if welcomeText.isEmpty {
|
||||||
TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder")))
|
TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder")))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
}
|
}
|
||||||
TextEditor(text: $welcomeText)
|
TextEditor(text: $welcomeText)
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
//
|
||||||
|
// ReverseList.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Levitating Pineapple on 11/06/2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
/// A List, which displays it's items in reverse order - from bottom to top
|
||||||
|
struct ReverseList<Content: View>: UIViewControllerRepresentable {
|
||||||
|
let items: Array<ChatItem>
|
||||||
|
|
||||||
|
@Binding var scrollState: ReverseListScrollModel.State
|
||||||
|
|
||||||
|
/// Closure, that returns user interface for a given item
|
||||||
|
let content: (ChatItem) -> Content
|
||||||
|
|
||||||
|
let loadPage: () -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> Controller {
|
||||||
|
Controller(representer: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ controller: Controller, context: Context) {
|
||||||
|
controller.representer = self
|
||||||
|
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
|
||||||
|
controller.view.layer.removeAllAnimations()
|
||||||
|
switch destination {
|
||||||
|
case .nextPage:
|
||||||
|
controller.scrollToNextPage()
|
||||||
|
case let .item(id):
|
||||||
|
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
||||||
|
case .bottom:
|
||||||
|
controller.scroll(to: 0, position: .top)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
controller.update(items: items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller, which hosts SwiftUI cells
|
||||||
|
class Controller: UITableViewController {
|
||||||
|
private enum Section { case main }
|
||||||
|
var representer: ReverseList
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
|
||||||
|
private var itemCount: Int = 0
|
||||||
|
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(representer: ReverseList) {
|
||||||
|
self.representer = representer
|
||||||
|
super.init(style: .plain)
|
||||||
|
|
||||||
|
// 1. Style
|
||||||
|
tableView = InvertedTableView()
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.transform = .verticalFlip
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
|
||||||
|
// 2. Register cells
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
tableView.register(
|
||||||
|
UITableViewCell.self,
|
||||||
|
forCellReuseIdentifier: cellReuseId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tableView.register(
|
||||||
|
HostingCell<Content>.self,
|
||||||
|
forCellReuseIdentifier: cellReuseId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Configure data source
|
||||||
|
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
|
||||||
|
tableView: tableView
|
||||||
|
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||||
|
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
|
||||||
|
self.representer.loadPage()
|
||||||
|
}
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
|
||||||
|
.margins(.all, 0)
|
||||||
|
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||||
|
} else {
|
||||||
|
if let cell = cell as? HostingCell<Content> {
|
||||||
|
cell.set(content: self.representer.content(item), parent: self)
|
||||||
|
} else {
|
||||||
|
fatalError("Unexpected Cell Type for: \(item)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell.transform = .verticalFlip
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
cell.backgroundColor = .clear
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. External state changes will require manual layout updates
|
||||||
|
NotificationCenter.default
|
||||||
|
.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(updateLayout),
|
||||||
|
name: notificationName,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
updateFloatingButtons
|
||||||
|
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
|
||||||
|
.sink {
|
||||||
|
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
|
||||||
|
ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &bag)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
deinit { NotificationCenter.default.removeObserver(self) }
|
||||||
|
|
||||||
|
@objc private func updateLayout() {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
tableView.setNeedsLayout()
|
||||||
|
tableView.layoutIfNeeded()
|
||||||
|
} else {
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides keyboard, when user begins to scroll.
|
||||||
|
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
|
||||||
|
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||||
|
UIApplication.shared
|
||||||
|
.sendAction(
|
||||||
|
#selector(UIResponder.resignFirstResponder),
|
||||||
|
to: nil,
|
||||||
|
from: nil,
|
||||||
|
for: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
tableView.clipsToBounds = false
|
||||||
|
parent?.viewIfLoaded?.clipsToBounds = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls up
|
||||||
|
func scrollToNextPage() {
|
||||||
|
tableView.setContentOffset(
|
||||||
|
CGPoint(
|
||||||
|
x: tableView.contentOffset.x,
|
||||||
|
y: tableView.contentOffset.y + tableView.bounds.height
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
Task { representer.scrollState = .atDestination }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls to Item at index path
|
||||||
|
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
||||||
|
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
|
||||||
|
var animated = false
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
animated = true
|
||||||
|
}
|
||||||
|
if let index, tableView.numberOfRows(inSection: 0) != 0 {
|
||||||
|
tableView.scrollToRow(
|
||||||
|
at: IndexPath(row: index, section: 0),
|
||||||
|
at: position,
|
||||||
|
animated: animated
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tableView.setContentOffset(
|
||||||
|
CGPoint(x: .zero, y: -InvertedTableView.inset),
|
||||||
|
animated: animated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Task { representer.scrollState = .atDestination }
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(items: [ChatItem]) {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
snapshot.appendItems(items)
|
||||||
|
dataSource.defaultRowAnimation = .none
|
||||||
|
dataSource.apply(
|
||||||
|
snapshot,
|
||||||
|
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
|
||||||
|
)
|
||||||
|
// Sets content offset on initial load
|
||||||
|
if itemCount == 0 {
|
||||||
|
tableView.setContentOffset(
|
||||||
|
CGPoint(x: 0, y: -InvertedTableView.inset),
|
||||||
|
animated: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
itemCount = items.count
|
||||||
|
updateFloatingButtons.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
updateFloatingButtons.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getListState() -> ListState? {
|
||||||
|
if let visibleRows = tableView.indexPathsForVisibleRows,
|
||||||
|
visibleRows.last?.item ?? 0 < representer.items.count {
|
||||||
|
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
|
||||||
|
let topItemDate: Date? =
|
||||||
|
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
|
||||||
|
representer.items[lastVisible.item].meta.itemTs
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
let bottomItemId: ChatItem.ID? =
|
||||||
|
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
|
||||||
|
representer.items[firstVisible.item].id
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isVisible(indexPath: IndexPath) -> Bool {
|
||||||
|
if let relativeFrame = tableView.superview?.convert(
|
||||||
|
tableView.rectForRow(at: indexPath),
|
||||||
|
from: tableView
|
||||||
|
) {
|
||||||
|
relativeFrame.maxY > InvertedTableView.inset &&
|
||||||
|
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
|
||||||
|
} else { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||||
|
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
|
||||||
|
private final class HostingCell<Hosted: View>: UITableViewCell {
|
||||||
|
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
|
||||||
|
|
||||||
|
/// Updates content of the cell
|
||||||
|
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
||||||
|
func set(content: Hosted, parent: UIViewController) {
|
||||||
|
hostingController.view.backgroundColor = .clear
|
||||||
|
hostingController.rootView = content
|
||||||
|
if let hostingView = hostingController.view {
|
||||||
|
hostingView.invalidateIntrinsicContentSize()
|
||||||
|
if hostingController.parent != parent { parent.addChild(hostingController) }
|
||||||
|
if !contentView.subviews.contains(hostingController.view) {
|
||||||
|
contentView.addSubview(hostingController.view)
|
||||||
|
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
hostingView.leadingAnchor
|
||||||
|
.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
hostingView.trailingAnchor
|
||||||
|
.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
hostingView.topAnchor
|
||||||
|
.constraint(equalTo: contentView.topAnchor),
|
||||||
|
hostingView.bottomAnchor
|
||||||
|
.constraint(equalTo: contentView.bottomAnchor)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
|
||||||
|
} else {
|
||||||
|
fatalError("Hosting View not loaded \(hostingController)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
hostingController.rootView = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias ListState = (
|
||||||
|
scrollOffset: Double,
|
||||||
|
topItemDate: Date?,
|
||||||
|
bottomItemId: ChatItem.ID?
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Manages ``ReverseList`` scrolling
|
||||||
|
class ReverseListScrollModel: ObservableObject {
|
||||||
|
/// Represents Scroll State of ``ReverseList``
|
||||||
|
enum State: Equatable {
|
||||||
|
enum Destination: Equatable {
|
||||||
|
case nextPage
|
||||||
|
case item(ChatItem.ID)
|
||||||
|
case bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
case scrollingTo(Destination)
|
||||||
|
case atDestination
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var state: State = .atDestination
|
||||||
|
|
||||||
|
func scrollToNextPage() {
|
||||||
|
state = .scrollingTo(.nextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollToBottom() {
|
||||||
|
state = .scrollingTo(.bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollToItem(id: ChatItem.ID) {
|
||||||
|
state = .scrollingTo(.item(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate let cellReuseId = "hostingCell"
|
||||||
|
|
||||||
|
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
|
||||||
|
|
||||||
|
fileprivate extension CGAffineTransform {
|
||||||
|
/// Transform that vertically flips the view, preserving it's location
|
||||||
|
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NotificationCenter {
|
||||||
|
static func postReverseListNeedsLayout() {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: notificationName,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable animation on iOS 15
|
||||||
|
func withConditionalAnimation<Result>(
|
||||||
|
_ animation: Animation? = .default,
|
||||||
|
_ body: () throws -> Result
|
||||||
|
) rethrows -> Result {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
try withAnimation(animation, body)
|
||||||
|
} else {
|
||||||
|
try body()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvertedTableView: UITableView {
|
||||||
|
static let inset = CGFloat(100)
|
||||||
|
|
||||||
|
static let insets = UIEdgeInsets(
|
||||||
|
top: inset,
|
||||||
|
left: .zero,
|
||||||
|
bottom: inset,
|
||||||
|
right: .zero
|
||||||
|
)
|
||||||
|
|
||||||
|
override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
|
||||||
|
get { .never }
|
||||||
|
set { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override var contentInset: UIEdgeInsets {
|
||||||
|
get { Self.insets }
|
||||||
|
set { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override var adjustedContentInset: UIEdgeInsets {
|
||||||
|
Self.insets
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//
|
||||||
|
// SelectableChatItemToolbars.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Stanislav Dmitrenko on 30.07.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct SelectedItemsTopToolbar: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Binding var selectedChatItems: Set<Int64>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let count = selectedChatItems?.count ?? 0
|
||||||
|
return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline)
|
||||||
|
.foregroundColor(theme.colors.onBackground)
|
||||||
|
.frame(width: 220)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SelectedItemsBottomToolbar: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
let chatItems: [ChatItem]
|
||||||
|
@Binding var selectedChatItems: Set<Int64>?
|
||||||
|
var chatInfo: ChatInfo
|
||||||
|
// Bool - delete for everyone is possible
|
||||||
|
var deleteItems: (Bool) -> Void
|
||||||
|
var moderateItems: () -> Void
|
||||||
|
//var shareItems: () -> Void
|
||||||
|
var forwardItems: () -> Void
|
||||||
|
@State var deleteEnabled: Bool = false
|
||||||
|
@State var deleteForEveryoneEnabled: Bool = false
|
||||||
|
|
||||||
|
@State var canModerate: Bool = false
|
||||||
|
@State var moderateEnabled: Bool = false
|
||||||
|
|
||||||
|
@State var forwardEnabled: Bool = false
|
||||||
|
|
||||||
|
@State var deleteCountProhibited = false
|
||||||
|
@State var forwardCountProhibited = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
Button {
|
||||||
|
deleteItems(deleteForEveryoneEnabled)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20, alignment: .center)
|
||||||
|
.foregroundColor(!deleteEnabled || deleteCountProhibited ? theme.colors.secondary: .red)
|
||||||
|
}
|
||||||
|
.disabled(!deleteEnabled || deleteCountProhibited)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
moderateItems()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "flag")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20, alignment: .center)
|
||||||
|
.foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red)
|
||||||
|
}
|
||||||
|
.disabled(!moderateEnabled || deleteCountProhibited)
|
||||||
|
.opacity(canModerate ? 1 : 0)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
forwardItems()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrowshape.turn.up.forward")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20, alignment: .center)
|
||||||
|
.foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary)
|
||||||
|
}
|
||||||
|
.disabled(!forwardEnabled || forwardCountProhibited)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.padding([.leading, .trailing], 12)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
recheckItems(chatInfo, chatItems, selectedChatItems)
|
||||||
|
}
|
||||||
|
.onChange(of: chatInfo) { info in
|
||||||
|
recheckItems(info, chatItems, selectedChatItems)
|
||||||
|
}
|
||||||
|
.onChange(of: chatItems) { items in
|
||||||
|
recheckItems(chatInfo, items, selectedChatItems)
|
||||||
|
}
|
||||||
|
.onChange(of: selectedChatItems) { selected in
|
||||||
|
recheckItems(chatInfo, chatItems, selected)
|
||||||
|
}
|
||||||
|
.frame(height: 55.5)
|
||||||
|
.background(.thinMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
|
||||||
|
let count = selectedItems?.count ?? 0
|
||||||
|
deleteCountProhibited = count == 0 || count > 200
|
||||||
|
forwardCountProhibited = count == 0 || count > 20
|
||||||
|
canModerate = possibleToModerate(chatInfo)
|
||||||
|
if let selected = selectedItems {
|
||||||
|
let me: Bool
|
||||||
|
let onlyOwnGroupItems: Bool
|
||||||
|
(deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in
|
||||||
|
if selected.contains(ci.id) {
|
||||||
|
var (de, dee, me, onlyOwnGroupItems, fe, sel) = r
|
||||||
|
de = de && ci.canBeDeletedForSelf
|
||||||
|
dee = dee && ci.meta.deletable && !ci.localNote
|
||||||
|
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
|
||||||
|
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
|
||||||
|
fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy
|
||||||
|
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
|
||||||
|
return (de, dee, me, onlyOwnGroupItems, fe, sel)
|
||||||
|
} else {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moderateEnabled = me && !onlyOwnGroupItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
|
||||||
|
return switch chatInfo {
|
||||||
|
case let .group(groupInfo):
|
||||||
|
groupInfo.membership.memberRole >= .admin
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct VerifyCodeView: View {
|
struct VerifyCodeView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var displayName: String
|
var displayName: String
|
||||||
@State var connectionCode: String?
|
@State var connectionCode: String?
|
||||||
@State var connectionVerified: Bool
|
@State var connectionVerified: Bool
|
||||||
@@ -30,7 +31,7 @@ struct VerifyCodeView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if connectionVerified {
|
if connectionVerified {
|
||||||
Image(systemName: "checkmark.shield")
|
Image(systemName: "checkmark.shield")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
Text("\(displayName) is verified")
|
Text("\(displayName) is verified")
|
||||||
} else {
|
} else {
|
||||||
Text("\(displayName) is not verified")
|
Text("\(displayName) is not verified")
|
||||||
@@ -66,6 +67,7 @@ struct VerifyCodeView: View {
|
|||||||
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
|
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.navigationTitle("Scan code")
|
.navigationTitle("Scan code")
|
||||||
|
.modifier(ThemedBackground())
|
||||||
} label: {
|
} label: {
|
||||||
Label("Scan code", systemImage: "qrcode")
|
Label("Scan code", systemImage: "qrcode")
|
||||||
}
|
}
|
||||||
@@ -122,5 +124,6 @@ struct VerifyCodeView: View {
|
|||||||
struct VerifyCodeView_Previews: PreviewProvider {
|
struct VerifyCodeView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil})
|
VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil})
|
||||||
|
.environmentObject(CurrentColors.toAppTheme())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ChatHelp: View {
|
struct ChatHelp: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Binding var showSettings: Bool
|
let dismissSettingsSheet: DismissAction
|
||||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView { chatHelp() }
|
ScrollView { chatHelp() }
|
||||||
@@ -24,7 +23,7 @@ struct ChatHelp: View {
|
|||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("To ask any questions and to receive updates:")
|
Text("To ask any questions and to receive updates:")
|
||||||
Button("connect to SimpleX Chat developers.") {
|
Button("connect to SimpleX Chat developers.") {
|
||||||
showSettings = false
|
dismissSettingsSheet()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
UIApplication.shared.open(simplexTeamURL)
|
UIApplication.shared.open(simplexTeamURL)
|
||||||
}
|
}
|
||||||
@@ -39,7 +38,7 @@ struct ChatHelp: View {
|
|||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("Tap button ")
|
Text("Tap button ")
|
||||||
NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
NewChatMenuButton()
|
||||||
Text("above, then choose:")
|
Text("above, then choose:")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +61,9 @@ struct ChatHelp: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ChatHelp_Previews: PreviewProvider {
|
struct ChatHelp_Previews: PreviewProvider {
|
||||||
|
@Environment(\.dismiss) static var mockDismiss
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
@State var showSettings = false
|
ChatHelp(dismissSettingsSheet: mockDismiss)
|
||||||
return ChatHelp(showSettings: $showSettings)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,34 +9,56 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
private let rowHeights: [DynamicTypeSize: CGFloat] = [
|
typealias DynamicSizes = (
|
||||||
.xSmall: 68,
|
rowHeight: CGFloat,
|
||||||
.small: 72,
|
profileImageSize: CGFloat,
|
||||||
.medium: 76,
|
mediaSize: CGFloat,
|
||||||
.large: 80,
|
incognitoSize: CGFloat,
|
||||||
.xLarge: 88,
|
chatInfoSize: CGFloat,
|
||||||
.xxLarge: 94,
|
unreadCorner: CGFloat,
|
||||||
.xxxLarge: 104,
|
unreadPadding: CGFloat
|
||||||
.accessibility1: 90,
|
)
|
||||||
.accessibility2: 100,
|
|
||||||
.accessibility3: 120,
|
private let dynamicSizes: [DynamicTypeSize: DynamicSizes] = [
|
||||||
.accessibility4: 130,
|
.xSmall: (68, 55, 33, 22, 18, 9, 3),
|
||||||
.accessibility5: 140
|
.small: (72, 57, 34, 22, 18, 9, 3),
|
||||||
|
.medium: (76, 60, 36, 22, 18, 10, 4),
|
||||||
|
.large: (80, 63, 38, 24, 20, 10, 4),
|
||||||
|
.xLarge: (88, 67, 41, 24, 20, 10, 4),
|
||||||
|
.xxLarge: (100, 71, 44, 27, 22, 11, 4),
|
||||||
|
.xxxLarge: (110, 75, 48, 30, 24, 12, 5),
|
||||||
|
.accessibility1: (110, 75, 48, 30, 24, 12, 5),
|
||||||
|
.accessibility2: (114, 75, 48, 30, 24, 12, 5),
|
||||||
|
.accessibility3: (124, 75, 48, 30, 24, 12, 5),
|
||||||
|
.accessibility4: (134, 75, 48, 30, 24, 12, 5),
|
||||||
|
.accessibility5: (144, 75, 48, 30, 24, 12, 5)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
private let defaultDynamicSizes: DynamicSizes = dynamicSizes[.large]!
|
||||||
|
|
||||||
|
func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
|
||||||
|
dynamicSizes[font] ?? defaultDynamicSizes
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatListNavLink: View {
|
struct ChatListNavLink: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||||
|
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@State private var showContactRequestDialog = false
|
@State private var showContactRequestDialog = false
|
||||||
@State private var showJoinGroupDialog = false
|
@State private var showJoinGroupDialog = false
|
||||||
@State private var showContactConnectionInfo = false
|
@State private var showContactConnectionInfo = false
|
||||||
@State private var showInvalidJSON = false
|
@State private var showInvalidJSON = false
|
||||||
@State private var showDeleteContactActionSheet = false
|
@State private var alert: SomeAlert? = nil
|
||||||
|
@State private var actionSheet: SomeActionSheet? = nil
|
||||||
|
@State private var sheet: SomeSheet<AnyView>? = nil
|
||||||
@State private var showConnectContactViaAddressDialog = false
|
@State private var showConnectContactViaAddressDialog = false
|
||||||
@State private var inProgress = false
|
@State private var inProgress = false
|
||||||
@State private var progressByTimeout = false
|
@State private var progressByTimeout = false
|
||||||
|
|
||||||
|
var dynamicRowHeight: CGFloat { dynamicSize(userFont).rowHeight }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
switch chat.chatInfo {
|
switch chat.chatInfo {
|
||||||
@@ -64,17 +86,24 @@ struct ChatListNavLink: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
|
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
|
||||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
Button {
|
Button {
|
||||||
showDeleteContactActionSheet = true
|
deleteContactDialog(
|
||||||
|
chat,
|
||||||
|
contact,
|
||||||
|
dismissToChatList: false,
|
||||||
|
showAlert: { alert = $0 },
|
||||||
|
showActionSheet: { actionSheet = $0 },
|
||||||
|
showSheetContent: { sheet = $0 }
|
||||||
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
deleteLabel
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
@@ -85,51 +114,44 @@ struct ChatListNavLink: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
NavLinkPlain(
|
NavLinkPlain(
|
||||||
tag: chat.chatInfo.id,
|
chatId: chat.chatInfo.id,
|
||||||
selection: $chatModel.chatId,
|
selection: $chatModel.chatId,
|
||||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
||||||
)
|
)
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
markReadButton()
|
markReadButton()
|
||||||
toggleFavoriteButton()
|
toggleFavoriteButton()
|
||||||
ToggleNtfsButton(chat: chat)
|
toggleNtfsButton(chat: chat)
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
if !chat.chatItems.isEmpty {
|
if !chat.chatItems.isEmpty {
|
||||||
clearChatButton()
|
clearChatButton()
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
if contact.ready || !contact.active {
|
deleteContactDialog(
|
||||||
showDeleteContactActionSheet = true
|
chat,
|
||||||
} else {
|
contact,
|
||||||
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
|
dismissToChatList: false,
|
||||||
}
|
showAlert: { alert = $0 },
|
||||||
|
showActionSheet: { actionSheet = $0 },
|
||||||
|
showSheetContent: { sheet = $0 }
|
||||||
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
deleteLabel
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
.alert(item: $alert) { $0.alert }
|
||||||
if contact.ready && contact.active {
|
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||||
return ActionSheet(
|
.sheet(item: $sheet) {
|
||||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
if #available(iOS 16.0, *) {
|
||||||
buttons: [
|
$0.content
|
||||||
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
|
.presentationDetents([.fraction(0.4)])
|
||||||
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
|
|
||||||
.cancel()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
return ActionSheet(
|
$0.content
|
||||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
|
||||||
buttons: [
|
|
||||||
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
|
|
||||||
.cancel()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +160,7 @@ struct ChatListNavLink: View {
|
|||||||
switch (groupInfo.membership.memberStatus) {
|
switch (groupInfo.membership.memberStatus) {
|
||||||
case .memInvited:
|
case .memInvited:
|
||||||
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
|
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
joinGroupButton()
|
joinGroupButton()
|
||||||
if groupInfo.canDelete {
|
if groupInfo.canDelete {
|
||||||
@@ -158,7 +180,7 @@ struct ChatListNavLink: View {
|
|||||||
.disabled(inProgress)
|
.disabled(inProgress)
|
||||||
case .memAccepted:
|
case .memAccepted:
|
||||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
|
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
|
||||||
}
|
}
|
||||||
@@ -172,16 +194,16 @@ struct ChatListNavLink: View {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
NavLinkPlain(
|
NavLinkPlain(
|
||||||
tag: chat.chatInfo.id,
|
chatId: chat.chatInfo.id,
|
||||||
selection: $chatModel.chatId,
|
selection: $chatModel.chatId,
|
||||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||||
disabled: !groupInfo.ready
|
disabled: !groupInfo.ready
|
||||||
)
|
)
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
markReadButton()
|
markReadButton()
|
||||||
toggleFavoriteButton()
|
toggleFavoriteButton()
|
||||||
ToggleNtfsButton(chat: chat)
|
toggleNtfsButton(chat: chat)
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
if !chat.chatItems.isEmpty {
|
if !chat.chatItems.isEmpty {
|
||||||
@@ -199,12 +221,12 @@ struct ChatListNavLink: View {
|
|||||||
|
|
||||||
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
|
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
|
||||||
NavLinkPlain(
|
NavLinkPlain(
|
||||||
tag: chat.chatInfo.id,
|
chatId: chat.chatInfo.id,
|
||||||
selection: $chatModel.chatId,
|
selection: $chatModel.chatId,
|
||||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||||
disabled: !noteFolder.ready
|
disabled: !noteFolder.ready
|
||||||
)
|
)
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
markReadButton()
|
markReadButton()
|
||||||
}
|
}
|
||||||
@@ -222,9 +244,9 @@ struct ChatListNavLink: View {
|
|||||||
await MainActor.run { inProgress = false }
|
await MainActor.run { inProgress = false }
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
|
SwipeLabel(NSLocalizedString("Join", comment: "swipe action"), systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(chat.chatInfo.incognito ? .indigo : .accentColor)
|
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func markReadButton() -> some View {
|
@ViewBuilder private func markReadButton() -> some View {
|
||||||
@@ -232,16 +254,16 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
Task { await markChatRead(chat) }
|
Task { await markChatRead(chat) }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Read", systemImage: "checkmark")
|
SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(Color.accentColor)
|
.tint(theme.colors.primary)
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
Task { await markChatUnread(chat) }
|
Task { await markChatUnread(chat) }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unread", systemImage: "circlebadge.fill")
|
SwipeLabel(NSLocalizedString("Unread", comment: "swipe action"), systemImage: "circlebadge.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(Color.accentColor)
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -251,24 +273,36 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
toggleChatFavorite(chat, favorite: false)
|
toggleChatFavorite(chat, favorite: false)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unfav.", systemImage: "star.slash")
|
SwipeLabel(NSLocalizedString("Unfav.", comment: "swipe action"), systemImage: "star.slash.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(.green)
|
.tint(.green)
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
toggleChatFavorite(chat, favorite: true)
|
toggleChatFavorite(chat, favorite: true)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Favorite", systemImage: "star.fill")
|
SwipeLabel(NSLocalizedString("Favorite", comment: "swipe action"), systemImage: "star.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(.green)
|
.tint(.green)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
|
||||||
|
Button {
|
||||||
|
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||||
|
} label: {
|
||||||
|
if chat.chatInfo.ntfsEnabled {
|
||||||
|
SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
|
||||||
|
} else {
|
||||||
|
SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func clearChatButton() -> some View {
|
private func clearChatButton() -> some View {
|
||||||
Button {
|
Button {
|
||||||
AlertManager.shared.showAlert(clearChatAlert())
|
AlertManager.shared.showAlert(clearChatAlert())
|
||||||
} label: {
|
} label: {
|
||||||
Label("Clear", systemImage: "gobackward")
|
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(Color.orange)
|
.tint(Color.orange)
|
||||||
}
|
}
|
||||||
@@ -277,7 +311,7 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
AlertManager.shared.showAlert(clearNoteFolderAlert())
|
AlertManager.shared.showAlert(clearNoteFolderAlert())
|
||||||
} label: {
|
} label: {
|
||||||
Label("Clear", systemImage: "gobackward")
|
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(Color.orange)
|
.tint(Color.orange)
|
||||||
}
|
}
|
||||||
@@ -286,7 +320,7 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
|
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
|
||||||
} label: {
|
} label: {
|
||||||
Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
|
SwipeLabel(NSLocalizedString("Leave", comment: "swipe action"), systemImage: "rectangle.portrait.and.arrow.right.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(Color.yellow)
|
.tint(Color.yellow)
|
||||||
}
|
}
|
||||||
@@ -295,7 +329,7 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
|
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
deleteLabel
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
@@ -305,22 +339,23 @@ struct ChatListNavLink: View {
|
|||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
Button {
|
Button {
|
||||||
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
||||||
} label: { Label("Accept", systemImage: "checkmark") }
|
} label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) }
|
||||||
.tint(.accentColor)
|
.tint(theme.colors.primary)
|
||||||
Button {
|
Button {
|
||||||
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
|
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Accept incognito", systemImage: "theatermasks")
|
SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(.indigo)
|
.tint(.indigo)
|
||||||
Button {
|
Button {
|
||||||
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
|
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
|
||||||
} label: {
|
} label: {
|
||||||
Label("Reject", systemImage: "multiply")
|
SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { showContactRequestDialog = true }
|
.onTapGesture { showContactRequestDialog = true }
|
||||||
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||||
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
|
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
|
||||||
@@ -337,29 +372,37 @@ struct ChatListNavLink: View {
|
|||||||
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
|
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
|
||||||
})
|
})
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
deleteLabel
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showContactConnectionInfo = true
|
showContactConnectionInfo = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Name", systemImage: "pencil")
|
SwipeLabel(NSLocalizedString("Name", comment: "swipe action"), systemImage: "pencil", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(.accentColor)
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.sheet(isPresented: $showContactConnectionInfo) {
|
.appSheet(isPresented: $showContactConnectionInfo) {
|
||||||
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
Group {
|
||||||
ContactConnectionInfo(contactConnection: contactConnection)
|
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
||||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
ContactConnectionInfo(contactConnection: contactConnection)
|
||||||
|
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
showContactConnectionInfo = true
|
showContactConnectionInfo = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var deleteLabel: some View {
|
||||||
|
SwipeLabel(NSLocalizedString("Delete", comment: "swipe action"), systemImage: "trash.fill", inverted: oneHandUI)
|
||||||
|
}
|
||||||
|
|
||||||
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Delete group?"),
|
title: Text("Delete group?"),
|
||||||
@@ -408,28 +451,6 @@ struct ChatListNavLink: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
|
|
||||||
Alert(
|
|
||||||
title: Text("Reject contact request"),
|
|
||||||
message: Text("The sender will NOT be notified"),
|
|
||||||
primaryButton: .destructive(Text("Reject")) {
|
|
||||||
Task { await rejectContactRequest(contactRequest) }
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
|
|
||||||
Alert(
|
|
||||||
title: Text("Contact is not connected yet!"),
|
|
||||||
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
|
|
||||||
primaryButton: .cancel(),
|
|
||||||
secondaryButton: .destructive(Text("Delete Contact")) {
|
|
||||||
removePendingContact(chat, contact)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func groupInvitationAcceptedAlert() -> Alert {
|
private func groupInvitationAcceptedAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Joining group"),
|
title: Text("Joining group"),
|
||||||
@@ -437,37 +458,13 @@ struct ChatListNavLink: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
|
|
||||||
Alert(
|
|
||||||
title: Text("Delete pending connection"),
|
|
||||||
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
|
|
||||||
primaryButton: .destructive(Text("Delete")) {
|
|
||||||
removePendingContact(chat, contact)
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removePendingContact(_ chat: Chat, _ contact: Contact) {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
chatModel.removeChat(contact.id)
|
|
||||||
}
|
|
||||||
} catch let error {
|
|
||||||
logger.error("ChatListNavLink.removePendingContact apiDeleteChat error: \(responseError(error))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func invalidJSONPreview(_ json: String) -> some View {
|
private func invalidJSONPreview(_ json: String) -> some View {
|
||||||
Text("invalid chat data")
|
Text("invalid chat data")
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.onTapGesture { showInvalidJSON = true }
|
.onTapGesture { showInvalidJSON = true }
|
||||||
.sheet(isPresented: $showInvalidJSON) {
|
.appSheet(isPresented: $showInvalidJSON) {
|
||||||
invalidJSONView(json)
|
invalidJSONView(json)
|
||||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||||
}
|
}
|
||||||
@@ -475,16 +472,26 @@ struct ChatListNavLink: View {
|
|||||||
|
|
||||||
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
|
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
|
||||||
Task {
|
Task {
|
||||||
let ok = await connectContactViaAddress(contact.contactId, incognito)
|
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
|
||||||
if ok {
|
if ok {
|
||||||
await MainActor.run {
|
ItemsModel.shared.loadOpenChat(contact.id)
|
||||||
chatModel.chatId = contact.id
|
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
|
||||||
|
Alert(
|
||||||
|
title: Text("Reject contact request"),
|
||||||
|
message: Text("The sender will NOT be notified"),
|
||||||
|
primaryButton: .destructive(Text("Reject")) {
|
||||||
|
Task { await rejectContactRequest(contactRequest) }
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
|
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Delete pending connection?"),
|
title: Text("Delete pending connection?"),
|
||||||
@@ -511,15 +518,14 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
|
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool {
|
||||||
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
|
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
|
||||||
if let alert = alert {
|
if let alert = alert {
|
||||||
AlertManager.shared.showAlert(alert)
|
showAlert(alert)
|
||||||
return false
|
return false
|
||||||
} else if let contact = contact {
|
} else if let contact = contact {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
ChatModel.shared.updateContact(contact)
|
ChatModel.shared.updateContact(contact)
|
||||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -560,18 +566,11 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ErrorAlert {
|
|
||||||
var title: LocalizedStringKey
|
|
||||||
var message: LocalizedStringKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
|
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
|
||||||
switch error as? ChatResponse {
|
if let r = error as? ChatResponse,
|
||||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
let alert = getNetworkErrorAlert(r) {
|
||||||
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
return alert
|
||||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
|
} else {
|
||||||
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
|
||||||
default:
|
|
||||||
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
|
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,93 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
|
enum UserPickerSheet: Identifiable {
|
||||||
|
case address
|
||||||
|
case chatPreferences
|
||||||
|
case chatProfiles
|
||||||
|
case currentProfile
|
||||||
|
case useFromDesktop
|
||||||
|
case settings
|
||||||
|
|
||||||
|
var id: Self { self }
|
||||||
|
|
||||||
|
var navigationTitle: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .address: "SimpleX address"
|
||||||
|
case .chatPreferences: "Your preferences"
|
||||||
|
case .chatProfiles: "Your chat profiles"
|
||||||
|
case .currentProfile: "Your current profile"
|
||||||
|
case .useFromDesktop: "Connect to desktop"
|
||||||
|
case .settings: "Your settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserPickerSheetView: View {
|
||||||
|
let sheet: UserPickerSheet
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@State private var loaded = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
ZStack {
|
||||||
|
if loaded, let currentUser = chatModel.currentUser {
|
||||||
|
switch sheet {
|
||||||
|
case .address:
|
||||||
|
UserAddressView(shareViaProfile: currentUser.addressShared)
|
||||||
|
case .chatPreferences:
|
||||||
|
PreferencesView(
|
||||||
|
profile: currentUser.profile,
|
||||||
|
preferences: currentUser.fullPreferences,
|
||||||
|
currentPreferences: currentUser.fullPreferences
|
||||||
|
)
|
||||||
|
case .chatProfiles:
|
||||||
|
UserProfilesView()
|
||||||
|
case .currentProfile:
|
||||||
|
UserProfile()
|
||||||
|
case .useFromDesktop:
|
||||||
|
ConnectDesktopView()
|
||||||
|
case .settings:
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Color.clear // Required for list background to be rendered during loading
|
||||||
|
}
|
||||||
|
.navigationTitle(sheet.navigationTitle)
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if let la = chatModel.laRequest {
|
||||||
|
LocalAuthView(authRequest: la)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
withAnimation(
|
||||||
|
.easeOut(duration: 0.1),
|
||||||
|
{ loaded = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatListView: View {
|
struct ChatListView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Binding var showSettings: Bool
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Binding var activeUserPickerSheet: UserPickerSheet?
|
||||||
@State private var searchMode = false
|
@State private var searchMode = false
|
||||||
@FocusState private var searchFocussed
|
@FocusState private var searchFocussed
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var searchShowingSimplexLink = false
|
@State private var searchShowingSimplexLink = false
|
||||||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
@State private var scrollToSearchBar = false
|
||||||
@State private var userPickerVisible = false
|
@State private var userPickerShown: Bool = false
|
||||||
@State private var showConnectDesktop = false
|
|
||||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||||
|
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||||
|
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
|
||||||
|
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||||
|
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
@@ -31,44 +106,48 @@ struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var viewBody: some View {
|
private var viewBody: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
|
||||||
NavStackCompat(
|
NavStackCompat(
|
||||||
isActive: Binding(
|
isActive: Binding(
|
||||||
get: { chatModel.chatId != nil },
|
get: { chatModel.chatId != nil },
|
||||||
set: { _ in }
|
set: { active in
|
||||||
|
if !active { chatModel.chatId = nil }
|
||||||
|
}
|
||||||
),
|
),
|
||||||
destination: chatView
|
destination: chatView
|
||||||
) {
|
) { chatListView }
|
||||||
VStack {
|
|
||||||
if chatModel.chats.isEmpty {
|
|
||||||
onboardingButtons()
|
|
||||||
}
|
|
||||||
chatListView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if userPickerVisible {
|
|
||||||
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
|
|
||||||
withAnimation {
|
|
||||||
userPickerVisible.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UserPicker(
|
|
||||||
showSettings: $showSettings,
|
|
||||||
showConnectDesktop: $showConnectDesktop,
|
|
||||||
userPickerVisible: $userPickerVisible
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showConnectDesktop) {
|
.modifier(
|
||||||
ConnectDesktopView()
|
Sheet(isPresented: $userPickerShown) {
|
||||||
|
UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet)
|
||||||
|
.privacySensitive(protectScreen)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.appSheet(
|
||||||
|
item: $activeUserPickerSheet,
|
||||||
|
onDismiss: { chatModel.laRequest = nil },
|
||||||
|
content: { UserPickerSheetView(sheet: $0) }
|
||||||
|
)
|
||||||
|
.onChange(of: activeUserPickerSheet) {
|
||||||
|
if $0 != nil {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
|
userPickerShown = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var chatListView: some View {
|
private var chatListView: some View {
|
||||||
VStack {
|
let tm = ToolbarMaterial.material(toolbarMaterial)
|
||||||
|
return withToolbar(tm) {
|
||||||
chatList
|
chatList
|
||||||
|
.background(theme.colors.background)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarHidden(searchMode || oneHandUI)
|
||||||
|
.privacySensitive(protectScreen)
|
||||||
}
|
}
|
||||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
|
.onDisappear() { activeUserPickerSheet = nil }
|
||||||
.refreshable {
|
.refreshable {
|
||||||
AlertManager.shared.showAlert(Alert(
|
AlertManager.shared.showAlert(Alert(
|
||||||
title: Text("Reconnect servers?"),
|
title: Text("Reconnect servers?"),
|
||||||
@@ -85,65 +164,109 @@ struct ChatListView: View {
|
|||||||
secondaryButton: .cancel()
|
secondaryButton: .cancel()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.safeAreaInset(edge: .top) {
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
if oneHandUI { Divider().background(tm) }
|
||||||
.navigationBarHidden(searchMode)
|
}
|
||||||
.toolbar {
|
.safeAreaInset(edge: .bottom) {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
if oneHandUI {
|
||||||
let user = chatModel.currentUser ?? User.sampleData
|
Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
|
|
||||||
.padding(.trailing, 4)
|
|
||||||
let allRead = chatModel.users
|
|
||||||
.filter { u in !u.user.activeUser && !u.user.hidden }
|
|
||||||
.allSatisfy { u in u.unreadCount == 0 }
|
|
||||||
if !allRead {
|
|
||||||
unreadBadge(size: 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
|
||||||
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
|
||||||
withAnimation {
|
|
||||||
userPickerVisible.toggle()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showSettings = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .principal) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("Chats")
|
|
||||||
.font(.headline)
|
|
||||||
if chatModel.chats.count > 0 {
|
|
||||||
toggleFilterButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
switch chatModel.chatRunning {
|
|
||||||
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
|
||||||
case .some(false): chatStoppedIcon()
|
|
||||||
case .none: EmptyView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleFilterButton() -> some View {
|
static var hasHomeIndicator: Bool = {
|
||||||
Button {
|
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
showUnreadAndFavorites = !showUnreadAndFavorites
|
let window = windowScene.windows.first {
|
||||||
} label: {
|
window.safeAreaInsets.bottom > 0
|
||||||
Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : ""))
|
} else { false }
|
||||||
.foregroundColor(.accentColor)
|
}()
|
||||||
|
|
||||||
|
@ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
if oneHandUI {
|
||||||
|
content()
|
||||||
|
.toolbarBackground(.hidden, for: .bottomBar)
|
||||||
|
.toolbar { bottomToolbar }
|
||||||
|
} else {
|
||||||
|
content()
|
||||||
|
.toolbarBackground(.automatic, for: .navigationBar)
|
||||||
|
.toolbarBackground(material)
|
||||||
|
.toolbar { topToolbar }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if oneHandUI {
|
||||||
|
content().toolbar { bottomToolbarGroup }
|
||||||
|
} else {
|
||||||
|
content().toolbar { topToolbar }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder var topToolbar: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem }
|
||||||
|
ToolbarItem(placement: .principal) { SubsStatusIndicator() }
|
||||||
|
ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder var bottomToolbar: some ToolbarContent {
|
||||||
|
let padding: Double = Self.hasHomeIndicator ? 0 : 14
|
||||||
|
ToolbarItem(placement: .bottomBar) {
|
||||||
|
HStack {
|
||||||
|
leadingToolbarItem.padding(.bottom, padding)
|
||||||
|
Spacer()
|
||||||
|
SubsStatusIndicator().padding(.bottom, padding)
|
||||||
|
Spacer()
|
||||||
|
trailingToolbarItem.padding(.bottom, padding)
|
||||||
|
}
|
||||||
|
.privacySensitive(protectScreen)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { scrollToSearchBar = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent {
|
||||||
|
let padding: Double = Self.hasHomeIndicator ? 0 : 14
|
||||||
|
ToolbarItemGroup(placement: .bottomBar) {
|
||||||
|
leadingToolbarItem.padding(.bottom, padding)
|
||||||
|
Spacer()
|
||||||
|
SubsStatusIndicator().padding(.bottom, padding)
|
||||||
|
Spacer()
|
||||||
|
trailingToolbarItem.padding(.bottom, padding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var leadingToolbarItem: some View {
|
||||||
|
let user = chatModel.currentUser ?? User.sampleData
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
|
||||||
|
.padding([.top, .trailing], 3)
|
||||||
|
let allRead = chatModel.users
|
||||||
|
.filter { u in !u.user.activeUser && !u.user.hidden }
|
||||||
|
.allSatisfy { u in u.unreadCount == 0 }
|
||||||
|
if !allRead {
|
||||||
|
unreadBadge(size: 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.privacySensitive(protectScreen)
|
||||||
|
.onTapGesture {
|
||||||
|
userPickerShown = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var trailingToolbarItem: some View {
|
||||||
|
Group {
|
||||||
|
switch chatModel.chatRunning {
|
||||||
|
case .some(true): NewChatMenuButton()
|
||||||
|
case .some(false): chatStoppedIcon()
|
||||||
|
case .none: EmptyView()
|
||||||
|
}
|
||||||
|
}.privacySensitive(protectScreen)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder private var chatList: some View {
|
@ViewBuilder private var chatList: some View {
|
||||||
let cs = filteredChats()
|
let cs = filteredChats()
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack {
|
ScrollViewReader { scrollProxy in
|
||||||
List {
|
List {
|
||||||
if !chatModel.chats.isEmpty {
|
if !chatModel.chats.isEmpty {
|
||||||
ChatListSearchBar(
|
ChatListSearchBar(
|
||||||
@@ -153,95 +276,108 @@ struct ChatListView: View {
|
|||||||
searchShowingSimplexLink: $searchShowingSimplexLink,
|
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||||
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
||||||
)
|
)
|
||||||
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, oneHandUI ? 8 : 0)
|
||||||
|
.id("searchBar")
|
||||||
}
|
}
|
||||||
ForEach(cs, id: \.viewId) { chat in
|
if !oneHandUICardShown {
|
||||||
ChatListNavLink(chat: chat)
|
OneHandUICard()
|
||||||
.padding(.trailing, -16)
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
ForEach(cs, id: \.viewId) { chat in
|
||||||
|
ChatListNavLink(chat: chat)
|
||||||
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
|
.padding(.trailing, -16)
|
||||||
|
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
.offset(x: -8)
|
||||||
|
} else {
|
||||||
|
ForEach(cs, id: \.viewId) { chat in
|
||||||
|
VStack(spacing: .zero) {
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, 16)
|
||||||
|
ChatListNavLink(chat: chat)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.background { theme.colors.background } // Hides default list selection colour
|
||||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.offset(x: -8)
|
|
||||||
}
|
}
|
||||||
}
|
.listStyle(.plain)
|
||||||
.onChange(of: chatModel.chatId) { _ in
|
.onChange(of: chatModel.chatId) { currentChatId in
|
||||||
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
|
if let chatId = chatModel.chatToTop, currentChatId != chatId {
|
||||||
chatModel.chatToTop = nil
|
chatModel.chatToTop = nil
|
||||||
chatModel.popChat(chatId)
|
chatModel.popChat(chatId)
|
||||||
|
}
|
||||||
|
stopAudioPlayer()
|
||||||
|
}
|
||||||
|
.onChange(of: chatModel.currentUser?.userId) { _ in
|
||||||
|
stopAudioPlayer()
|
||||||
|
}
|
||||||
|
.onChange(of: scrollToSearchBar) { scrollToSearchBar in
|
||||||
|
if scrollToSearchBar {
|
||||||
|
Task { self.scrollToSearchBar = false }
|
||||||
|
withAnimation { scrollProxy.scrollTo("searchBar") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cs.isEmpty && !chatModel.chats.isEmpty {
|
if cs.isEmpty && !chatModel.chats.isEmpty {
|
||||||
Text("No filtered chats").foregroundColor(.secondary)
|
Text("No filtered chats")
|
||||||
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
|
private func unreadBadge(size: CGFloat = 18) -> some View {
|
||||||
Circle()
|
Circle()
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
}
|
|
||||||
|
|
||||||
private func onboardingButtons() -> some View {
|
|
||||||
VStack(alignment: .trailing, spacing: 0) {
|
|
||||||
Path { p in
|
|
||||||
p.move(to: CGPoint(x: 8, y: 0))
|
|
||||||
p.addLine(to: CGPoint(x: 16, y: 10))
|
|
||||||
p.addLine(to: CGPoint(x: 0, y: 10))
|
|
||||||
p.addLine(to: CGPoint(x: 8, y: 0))
|
|
||||||
}
|
|
||||||
.fill(Color.accentColor)
|
|
||||||
.frame(width: 20, height: 10)
|
|
||||||
.padding(.trailing, 12)
|
|
||||||
|
|
||||||
connectButton("Tap to start a new chat") {
|
|
||||||
newChatMenuOption = .newContact
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
Text("You have no chats")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 6)
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
|
|
||||||
Button(action: action) {
|
|
||||||
Text(label)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
.background(Color.accentColor)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func chatView() -> some View {
|
@ViewBuilder private func chatView() -> some View {
|
||||||
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
|
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
|
||||||
ChatView(chat: chat).onAppear {
|
ChatView(chat: chat)
|
||||||
loadChat(chat: chat)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stopAudioPlayer() {
|
||||||
|
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
|
||||||
|
VoiceItemState.smallView = [:]
|
||||||
|
}
|
||||||
|
|
||||||
private func filteredChats() -> [Chat] {
|
private func filteredChats() -> [Chat] {
|
||||||
if let linkChatId = searchChatFilteredBySimplexLink {
|
if let linkChatId = searchChatFilteredBySimplexLink {
|
||||||
return chatModel.chats.filter { $0.id == linkChatId }
|
return chatModel.chats.filter { $0.id == linkChatId }
|
||||||
} else {
|
} else {
|
||||||
let s = searchString()
|
let s = searchString()
|
||||||
return s == "" && !showUnreadAndFavorites
|
return s == "" && !showUnreadAndFavorites
|
||||||
? chatModel.chats
|
? chatModel.chats.filter { chat in
|
||||||
|
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
|
||||||
|
}
|
||||||
: chatModel.chats.filter { chat in
|
: chatModel.chats.filter { chat in
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
switch cInfo {
|
switch cInfo {
|
||||||
case let .direct(contact):
|
case let .direct(contact):
|
||||||
return s == ""
|
return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && (
|
||||||
? filtered(chat)
|
s == ""
|
||||||
: (viewNameContains(cInfo, s) ||
|
? filtered(chat)
|
||||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
: (viewNameContains(cInfo, s) ||
|
||||||
contact.fullName.localizedLowercase.contains(s))
|
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||||
|
contact.fullName.localizedLowercase.contains(s))
|
||||||
|
)
|
||||||
case let .group(gInfo):
|
case let .group(gInfo):
|
||||||
return s == ""
|
return s == ""
|
||||||
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
||||||
@@ -274,17 +410,78 @@ struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SubsStatusIndicator: View {
|
||||||
|
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
|
||||||
|
@State private var hasSess: Bool = false
|
||||||
|
@State private var task: Task<Void, Never>?
|
||||||
|
@State private var showServersSummary = false
|
||||||
|
|
||||||
|
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||||
|
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
showServersSummary = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("Chats").foregroundStyle(Color.primary).fixedSize().font(.headline)
|
||||||
|
SubscriptionStatusIndicatorView(subs: subs, hasSess: hasSess)
|
||||||
|
if showSubscriptionPercentage {
|
||||||
|
SubscriptionStatusPercentageView(subs: subs, hasSess: hasSess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(ChatModel.shared.chatRunning != true)
|
||||||
|
.privacySensitive(protectScreen)
|
||||||
|
.onAppear {
|
||||||
|
startTask()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopTask()
|
||||||
|
}
|
||||||
|
.appSheet(isPresented: $showServersSummary) {
|
||||||
|
ServersSummaryView()
|
||||||
|
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTask() {
|
||||||
|
task = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
if AppChatState.shared.value == .active, ChatModel.shared.chatRunning == true {
|
||||||
|
do {
|
||||||
|
let (subs, hasSess) = try await getAgentSubsTotal()
|
||||||
|
await MainActor.run {
|
||||||
|
self.subs = subs
|
||||||
|
self.hasSess = hasSess
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("getSubsTotal error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTask() {
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatListSearchBar: View {
|
struct ChatListSearchBar: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var searchMode: Bool
|
@Binding var searchMode: Bool
|
||||||
@FocusState.Binding var searchFocussed: Bool
|
@FocusState.Binding var searchFocussed: Bool
|
||||||
@Binding var searchText: String
|
@Binding var searchText: String
|
||||||
@Binding var searchShowingSimplexLink: Bool
|
@Binding var searchShowingSimplexLink: Bool
|
||||||
@Binding var searchChatFilteredBySimplexLink: String?
|
@Binding var searchChatFilteredBySimplexLink: String?
|
||||||
@State private var ignoreSearchTextChange = false
|
@State private var ignoreSearchTextChange = false
|
||||||
@State private var showScanCodeSheet = false
|
|
||||||
@State private var alert: PlanAndConnectAlert?
|
@State private var alert: PlanAndConnectAlert?
|
||||||
@State private var sheet: PlanAndConnectActionSheet?
|
@State private var sheet: PlanAndConnectActionSheet?
|
||||||
|
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
@@ -292,7 +489,7 @@ struct ChatListSearchBar: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
TextField("Search or paste SimpleX link", text: $searchText)
|
TextField("Search or paste SimpleX link", text: $searchText)
|
||||||
.foregroundColor(searchShowingSimplexLink ? .secondary : .primary)
|
.foregroundColor(searchShowingSimplexLink ? theme.colors.secondary : theme.colors.onBackground)
|
||||||
.disabled(searchShowingSimplexLink)
|
.disabled(searchShowingSimplexLink)
|
||||||
.focused($searchFocussed)
|
.focused($searchFocussed)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -301,47 +498,24 @@ struct ChatListSearchBar: View {
|
|||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
}
|
}
|
||||||
} else if !searchFocussed {
|
|
||||||
HStack(spacing: 24) {
|
|
||||||
if m.pasteboardHasStrings {
|
|
||||||
Image(systemName: "doc")
|
|
||||||
.onTapGesture {
|
|
||||||
if let str = UIPasteboard.general.string {
|
|
||||||
searchText = str
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(systemName: "qrcode")
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
.onTapGesture {
|
|
||||||
showScanCodeSheet = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.trailing, 2)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
|
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.background(Color(.tertiarySystemFill))
|
.background(Color(.tertiarySystemFill))
|
||||||
.cornerRadius(10.0)
|
.cornerRadius(10.0)
|
||||||
|
|
||||||
if searchFocussed {
|
if searchFocussed {
|
||||||
Text("Cancel")
|
Text("Cancel")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
searchFocussed = false
|
searchFocussed = false
|
||||||
}
|
}
|
||||||
|
} else if m.chats.count > 0 {
|
||||||
|
toggleFilterButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Divider()
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showScanCodeSheet) {
|
|
||||||
NewChatView(selection: .connect, showQRCodeScanner: true)
|
|
||||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) // fixes .refreshable in ChatListView affecting nested view
|
|
||||||
}
|
}
|
||||||
.onChange(of: searchFocussed) { sf in
|
.onChange(of: searchFocussed) { sf in
|
||||||
withAnimation { searchMode = sf }
|
withAnimation { searchMode = sf }
|
||||||
@@ -376,6 +550,21 @@ struct ChatListSearchBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func toggleFilterButton() -> some View {
|
||||||
|
ZStack {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary)
|
||||||
|
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
|
||||||
|
.onTapGesture {
|
||||||
|
showUnreadAndFavorites = !showUnreadAndFavorites
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func connect(_ link: String) {
|
private func connect(_ link: String) {
|
||||||
planAndConnect(
|
planAndConnect(
|
||||||
link,
|
link,
|
||||||
@@ -401,27 +590,29 @@ func chatStoppedIcon() -> some View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ChatListView_Previews: PreviewProvider {
|
struct ChatListView_Previews: PreviewProvider {
|
||||||
|
@State static var userPickerSheet: UserPickerSheet? = .none
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let chatModel = ChatModel()
|
let chatModel = ChatModel()
|
||||||
chatModel.chats = [
|
chatModel.updateChats([
|
||||||
Chat(
|
ChatData(
|
||||||
chatInfo: ChatInfo.sampleData.direct,
|
chatInfo: ChatInfo.sampleData.direct,
|
||||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
|
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
|
||||||
),
|
),
|
||||||
Chat(
|
ChatData(
|
||||||
chatInfo: ChatInfo.sampleData.group,
|
chatInfo: ChatInfo.sampleData.group,
|
||||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")]
|
chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")]
|
||||||
),
|
),
|
||||||
Chat(
|
ChatData(
|
||||||
chatInfo: ChatInfo.sampleData.contactRequest,
|
chatInfo: ChatInfo.sampleData.contactRequest,
|
||||||
chatItems: []
|
chatItems: []
|
||||||
)
|
)
|
||||||
|
|
||||||
]
|
])
|
||||||
return Group {
|
return Group {
|
||||||
ChatListView(showSettings: Binding.constant(false))
|
ChatListView(activeUserPickerSheet: $userPickerSheet)
|
||||||
.environmentObject(chatModel)
|
.environmentObject(chatModel)
|
||||||
ChatListView(showSettings: Binding.constant(false))
|
ChatListView(activeUserPickerSheet: $userPickerSheet)
|
||||||
.environmentObject(ChatModel())
|
.environmentObject(ChatModel())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||