Compare commits

..

219 Commits

Author SHA1 Message Date
Evgeny Poberezkin afa6083cb3 Merge branch 'master' into master-android 2024-08-11 14:36:31 +01:00
Evgeny Poberezkin 160c253966 Merge branch 'master' into master-android 2024-08-10 21:53:33 +01:00
Evgeny Poberezkin 3d7434c52f Merge branch 'master' into master-android 2024-08-09 23:35:01 +01:00
Evgeny Poberezkin c78df15cd6 Merge branch 'master' into master-android 2024-08-08 13:19:17 +01:00
Evgeny Poberezkin 701bef2a50 Merge branch 'master' into master-android 2024-08-06 19:55:42 +01:00
Evgeny Poberezkin 560d9b1b03 Merge branch 'master' into master-android 2024-08-03 08:58:20 +01:00
Evgeny Poberezkin b82349164f Merge branch 'master' into master-android 2024-07-29 22:14:48 +01:00
Evgeny Poberezkin 30df42d20d Merge branch master into master-android 2024-07-28 22:08:05 +01:00
Evgeny Poberezkin 01345cd423 Merge branch 'master' into master-android 2024-07-15 23:06:44 +01:00
Evgeny Poberezkin 38a2f3b458 Merge branch 'master' into master-android 2024-07-09 19:42:02 +01:00
Evgeny Poberezkin f739c7f226 Merge branch 'master' into master-android 2024-07-09 19:08:24 +01:00
Evgeny Poberezkin 28fb67fe92 Merge branch 'master' into master-android 2024-07-03 09:08:20 +01:00
Evgeny Poberezkin d2b643de01 Merge branch 'master' into master-android 2024-07-02 15:08:07 +01:00
Evgeny Poberezkin bae9685950 Merge branch 'master' into master-android 2024-06-20 20:34:11 +01:00
Evgeny Poberezkin ccd34f5ab1 Merge branch 'master' into master-android 2024-06-18 23:40:07 +01:00
Evgeny Poberezkin dd8081a9d4 Merge branch 'master' into master-android 2024-06-03 14:06:41 +01:00
Evgeny Poberezkin d65909cb53 Merge branch 'master' into master-android 2024-06-01 00:23:02 +01:00
Evgeny Poberezkin e99b543c2f Merge branch 'master' into master-android 2024-05-29 08:34:51 +01:00
Evgeny Poberezkin 312d315d73 Merge branch 'master' into master-android 2024-05-24 21:34:06 +01:00
Evgeny Poberezkin cdf5520084 Merge branch 'master' into master-android 2024-05-20 19:04:29 +01:00
Evgeny Poberezkin 170e8ddeaf Merge branch 'master' into master-android 2024-05-17 15:41:24 +01:00
Evgeny Poberezkin 68f7956512 Merge branch 'master' into master-android 2024-05-13 21:13:29 +01:00
Evgeny Poberezkin e424b3eb66 Merge branch 'master' into master-android 2024-05-12 11:37:39 +01:00
Evgeny Poberezkin fb7a414e3c Merge branch 'master' into master-android 2024-05-11 23:56:47 +01:00
Evgeny Poberezkin 5966d6d648 Merge branch 'master' into master-android 2024-05-10 22:17:57 +01:00
Evgeny Poberezkin c6ebc9a4d9 Merge branch 'master' into master-android 2024-05-10 15:25:58 +01:00
Evgeny Poberezkin 05ec8b0676 Merge branch 'master' into master-android 2024-05-05 21:02:59 +01:00
Evgeny Poberezkin 6980f643ff Merge branch 'master' into master-android 2024-05-05 17:43:26 +01:00
Evgeny Poberezkin b60dd3e3e8 Merge branch 'master' into master-android 2024-05-02 16:48:46 +01:00
Evgeny Poberezkin 17a5770442 Merge branch 'master' into master-android 2024-04-27 09:24:01 +01:00
Evgeny Poberezkin 439bbc15a5 Merge branch 'master' into master-android 2024-04-26 15:42:30 +01:00
Evgeny Poberezkin 3688141972 Merge branch 'master' into master-android 2024-04-25 13:40:50 +01:00
Evgeny Poberezkin d79f8f8345 Merge branch 'master' into master-android 2024-04-25 11:15:41 +01:00
Evgeny Poberezkin 923ad7470c Merge branch 'master' into master-android 2024-04-22 22:24:30 +01:00
Evgeny Poberezkin 833abb252e Merge branch 'master' into master-android 2024-04-22 17:53:11 +01:00
Evgeny Poberezkin c60207f211 Merge branch 'master' into master-android 2024-04-22 08:08:03 +01:00
Evgeny Poberezkin d5665a421f Merge branch 'master' into master-android 2024-04-20 19:39:40 +01:00
Evgeny Poberezkin b2aa0ffc3c Merge branch 'master' into master-android 2024-04-19 21:18:33 +01:00
Evgeny Poberezkin e50a5cfaa1 Merge branch 'master' into master-android 2024-04-03 11:44:21 +01:00
Evgeny Poberezkin 73ec44a9be Merge branch 'master' into master-android 2024-04-01 14:37:08 +01:00
Evgeny Poberezkin 59fe6ab8ce Merge branch 'master' into master-android 2024-03-21 19:00:42 +00:00
Evgeny Poberezkin ec64be7c89 Merge branch 'master' into master-android 2024-03-19 16:08:24 +00:00
Evgeny Poberezkin 41984e56c0 Merge branch 'master' into master-android 2024-03-12 18:29:34 +00:00
Evgeny Poberezkin c435510b2e Merge branch 'master' into master-android 2024-03-12 15:21:02 +00:00
Evgeny Poberezkin de65d0e0a2 Merge branch 'master' into master-android 2024-03-11 09:27:17 +00:00
Evgeny Poberezkin 9cde37c6f5 Merge branch 'master' into master-android 2024-02-11 11:41:36 +00:00
Evgeny Poberezkin 9514acf6aa Merge branch 'master' into master-android 2024-02-03 18:39:08 +00:00
Evgeny Poberezkin 40d54f3f16 Merge branch 'master' into master-android 2024-02-02 08:32:46 +00:00
Evgeny Poberezkin 3249c5e463 Merge branch 'master' into master-android 2024-01-30 23:18:11 +00:00
Evgeny Poberezkin e440fba582 Merge branch 'master' into master-android 2024-01-27 20:06:31 +00:00
Evgeny Poberezkin 43cedbce26 Merge branch 'master' into master-android 2024-01-23 21:13:20 +00:00
spaced4ndy ebad009553 Merge branch 'master-ghc8107' into master-android 2024-01-22 18:44:55 +04:00
spaced4ndy 35cf113d98 Merge branch 'master' into master-ghc8107 2024-01-22 18:44:13 +04:00
Evgeny Poberezkin e82d2cbea2 Merge branch 'master-ghc8107' into master-android 2024-01-20 21:36:45 +00:00
Evgeny Poberezkin a0e056994e Merge branch 'master' into master-ghc8107 2024-01-20 21:36:23 +00:00
Evgeny Poberezkin e4d8ea17fc Merge branch 'master-ghc8107' into master-android 2024-01-20 15:07:37 +00:00
Evgeny Poberezkin e69db17804 Merge branch 'master' into master-ghc8107 2024-01-20 15:07:20 +00:00
Evgeny Poberezkin 9282cca396 Merge branch 'master-ghc8107' into master-android 2024-01-17 19:30:14 +00:00
Evgeny Poberezkin ad2eaeb004 Merge branch 'master' into master-ghc8107 2024-01-17 19:29:49 +00:00
Evgeny Poberezkin 3f44c9af24 Merge branch 'master-ghc8107' into master-android 2024-01-10 12:09:28 +00:00
Evgeny Poberezkin 4296515473 Merge branch 'master' into master-ghc8107 2024-01-10 12:06:48 +00:00
Evgeny Poberezkin 88830a2e09 Merge branch 'master-ghc8107' into master-android 2024-01-09 11:07:50 +00:00
Evgeny Poberezkin c8a269e391 Merge branch 'master' into master-ghc8107 2024-01-09 11:07:32 +00:00
Evgeny Poberezkin a45897007c Merge branch 'master-ghc8107' into master-android 2023-12-30 19:32:05 +00:00
Evgeny Poberezkin 42ea1df342 Merge branch 'master' into master-ghc8107 2023-12-30 19:19:39 +00:00
Evgeny Poberezkin 47214e33ea Merge branch 'master-ghc8107' into master-android 2023-12-27 21:02:49 +00:00
Evgeny Poberezkin ff239d81e5 Merge branch 'master' into master-ghc8107 2023-12-27 21:02:11 +00:00
Evgeny Poberezkin 0a6e47dd61 Merge branch 'master' into master-ghc8107 2023-12-27 20:57:25 +00:00
Evgeny Poberezkin ee75219ed3 core: 5.4.2.1 (simplexmq 5.5.0.2) 2023-12-27 20:57:05 +00:00
Evgeny Poberezkin 2e337a753f Merge branch 'master-ghc8107' into master-android 2023-12-27 14:09:42 +00:00
Evgeny Poberezkin 9918d91def Merge branch 'master' into master-ghc8107 2023-12-27 14:09:23 +00:00
Evgeny Poberezkin 82dd5751c1 Merge branch 'master-ghc8107' into master-android 2023-12-26 21:27:06 +00:00
Evgeny Poberezkin de637fab50 Merge branch 'master' into master-ghc8107 2023-12-26 21:12:36 +00:00
Evgeny Poberezkin cb49f6f01d Merge branch 'master-ghc8107' into master-android 2023-12-23 19:57:27 +00:00
Evgeny Poberezkin 6d0a83aa58 Merge branch 'master' into master-ghc8107 2023-12-23 19:57:08 +00:00
Evgeny Poberezkin 05f57e98ad Merge branch 'master-ghc8107' into master-android 2023-12-23 17:25:14 +00:00
Evgeny Poberezkin 6a66525927 Merge branch 'master' into master-ghc8107 2023-12-23 17:24:47 +00:00
Evgeny Poberezkin 9530a6055a Merge branch 'master-ghc8107' into master-android 2023-12-23 15:12:49 +00:00
Evgeny Poberezkin f5ed8debcc Merge branch 'master' into master-ghc8107 2023-12-23 15:11:38 +00:00
Evgeny Poberezkin 355d2449c5 fix for GHC 8.10.7 2023-12-23 15:05:36 +00:00
Evgeny Poberezkin f7382cdd6f Merge branch 'master' into master-ghc8107 2023-12-23 13:52:44 +00:00
Evgeny Poberezkin dc8d10d068 core: limit process package version to 1.6.17 2023-12-21 10:54:54 +00:00
Evgeny Poberezkin 951245d33f Merge branch 'master-ghc8107' into master-android 2023-12-21 10:00:28 +00:00
Evgeny Poberezkin 521b901cc9 Merge branch 'master' into master-ghc8107 (without changes for iPhone7) 2023-12-21 09:59:25 +00:00
Evgeny Poberezkin 6311ba451b Merge branch 'master-ghc8107' into master-android 2023-12-21 00:45:32 +00:00
Evgeny Poberezkin 754c76d6fd Merge branch 'master' into master-ghc8107 2023-12-21 00:45:10 +00:00
Evgeny Poberezkin aea7ff1c89 nix: fix script 2023-12-13 20:27:58 +00:00
Evgeny Poberezkin 77ac972e09 Merge branch 'master-ghc8107' into master-android 2023-12-13 12:36:06 +00:00
Evgeny Poberezkin 256f85024f Merge branch 'master' into master-ghc8107 2023-12-13 12:35:37 +00:00
Evgeny Poberezkin 870f9e42dd Merge branch 'master' into master-ghc8107 2023-12-12 20:09:39 +00:00
Evgeny Poberezkin 0a4a3a24e1 Merge branch 'master-ghc8107' into master-android 2023-12-11 17:36:51 +00:00
Evgeny Poberezkin 8a66390a78 fix for GHC 8.10.7 2023-12-11 16:14:56 +00:00
Evgeny Poberezkin 3d48eded3d Merge branch 'master-ghc8107' into master-android 2023-12-11 14:56:47 +00:00
Evgeny Poberezkin 6546426ec0 Merge branch 'master' into master-ghc8107 2023-12-11 14:56:25 +00:00
spaced4ndy 4a7ceb00fb Merge branch 'master-ghc8107' into master-android 2023-12-11 17:53:08 +04:00
spaced4ndy 53560378bb Merge branch 'master' into master-ghc8107 2023-12-11 17:52:48 +04:00
Evgeny Poberezkin cdb3b6aafd Merge branch 'master-ghc8107' into master-android 2023-12-07 15:12:49 +00:00
Evgeny Poberezkin 9f3d3e8ba4 Merge branch 'master' into master-ghc8107 2023-12-07 15:00:23 +00:00
Evgeny Poberezkin 047aad592e Merge branch 'master-ghc8107' into master-android 2023-12-04 12:32:05 +00:00
Evgeny Poberezkin 087acd9180 changes to support GHC 8.10.7 (#3512)
* Revert "raise lower bound on mtl to a real version (#3499)"

This reverts commit f94c0311c1.

* Revert "core: expand ranges to fit ghc 8.10 & 9.6 (#3496)"

This reverts commit 9a1c7f41f7.

* update simplexmq

* remove netword-transport fork

* compatibility with GHC 8.10.7

* simplexmq

* fix test

* simplexmq, deps

* update sqlcipher deps in sha256nix

* fix index-state in cabal.project

* index-state

* remove import

* add cabal.project.freeze

* simplexmq

* remove freeze

* simplexmq

* bytestring,simplexmq

* template-haskell, simplexmq

* simplexmq

* simplexmq

* simplexmq

* mtl

* simplexmq

* remove duplicate index-state
2023-12-04 12:29:49 +00:00
Evgeny Poberezkin 0b822e4a5c Merge branch 'master' into master-ghc8107 2023-12-04 10:07:16 +00:00
Evgeny Poberezkin f8a469488e Merge branch 'master' into master-ghc8107 2023-12-02 12:30:36 +00:00
Evgeny Poberezkin 3b5e806418 Merge branch 'master' into master-ghc8107 2023-12-01 17:46:12 +00:00
spaced4ndy 79e208193a Merge branch 'master-ghc8107' into master-android 2023-11-30 21:11:25 +04:00
spaced4ndy ef5c13b1c1 Merge branch 'master' into master-ghc8107 2023-11-30 21:10:51 +04:00
spaced4ndy 38533213d2 Merge branch 'master' into master-ghc8107 2023-11-30 20:56:51 +04:00
spaced4ndy 5f1aa6fa9d Merge branch 'master-ghc8107' into master-android 2023-11-27 19:18:22 +04:00
spaced4ndy b0002fe07d Merge branch 'master' into master-ghc8107 2023-11-27 19:17:39 +04:00
Evgeny Poberezkin ef21fd1d26 Merge branch 'master-ghc8107' into master-android 2023-11-26 19:59:46 +00:00
Evgeny Poberezkin 4a311b9578 fix for ghc 8.10.7 2023-11-26 19:34:39 +00:00
Evgeny Poberezkin b8da5e225b Merge branch 'master' into master-ghc8107 2023-11-26 18:53:40 +00:00
Evgeny Poberezkin f27de052cf Merge branch 'master' into master-android 2023-11-26 11:51:16 +00:00
Evgeny Poberezkin 5cc537f14c Merge branch 'master' into master-ghc8107 2023-11-26 11:50:50 +00:00
Evgeny Poberezkin dc8ca4cf89 Merge tag 'v5.4.0' into master-android 2023-11-25 11:23:23 +00:00
Evgeny Poberezkin b62dd801f1 Merge branch 'master-ghc8107' into master-android 2023-11-24 20:02:05 +00:00
Evgeny Poberezkin 0c096e2c89 Merge branch 'master' into master-ghc8107 2023-11-24 19:00:30 +00:00
Evgeny Poberezkin cc127e56fe Merge branch 'master' into master-android 2023-11-23 16:23:02 +00:00
Evgeny Poberezkin 1781495ee3 Merge branch 'master' into master-ghc8107 2023-11-23 16:22:46 +00:00
Evgeny Poberezkin 831231d8e6 Merge branch 'master-ghc8107' into master-android 2023-11-21 21:16:04 +00:00
Evgeny Poberezkin 45102442f4 Merge branch 'master' into master-ghc8107 2023-11-21 21:15:10 +00:00
spaced4ndy f323c8e112 Merge branch 'master-ghc8107' into master-android 2023-11-21 19:42:28 +04:00
spaced4ndy 3bdc6b5e28 Merge branch 'master' into master-ghc8107 2023-11-21 19:41:06 +04:00
Evgeny Poberezkin d8373262bc Merge branch 'master-ghc8107' into master-android 2023-11-21 00:01:20 +00:00
Evgeny Poberezkin 3597d34716 Merge branch 'master' into master-ghc8107 2023-11-21 00:00:59 +00:00
Evgeny Poberezkin bd4259e89e update hpack 2023-11-20 14:43:20 +00:00
Evgeny Poberezkin 55ead740cc update hpack 2023-11-20 14:43:05 +00:00
Evgeny Poberezkin 5ef0eda2d7 Merge branch 'master-ghc8107' into master-android 2023-11-20 14:30:27 +00:00
Evgeny Poberezkin 49a9b0e7d6 update hpack version 2023-11-20 14:30:10 +00:00
Evgeny Poberezkin 45ada450a2 Merge branch 'master-ghc8107' into master-android 2023-11-20 13:24:07 +00:00
Evgeny Poberezkin 307a1b3c5e fix for ghc 8.10.7 2023-11-20 13:23:45 +00:00
Evgeny Poberezkin ed6b3bbead Merge branch 'master' into master-ghc8107 2023-11-20 13:01:22 +00:00
spaced4ndy 901610eec5 Merge branch 'master-ghc8107' into master-android 2023-11-20 14:08:10 +04:00
spaced4ndy 7d4127c51d Merge branch 'master' into master-ghc8107 2023-11-20 14:07:08 +04:00
Evgeny Poberezkin 13215d91d7 Merge branch 'master-ghc8107' into master-android 2023-11-20 00:07:12 +00:00
Evgeny Poberezkin e1a8099474 fix for GHC 8.10.7 2023-11-20 00:06:45 +00:00
Evgeny Poberezkin daa8d9bb21 Merge branch 'master' into master-ghc8107 2023-11-19 23:42:13 +00:00
Evgeny Poberezkin 5fcbade1bc Merge branch 'master-ghc8107' into master-android 2023-11-17 11:49:35 +00:00
Evgeny Poberezkin 3937ffa9a6 Merge branch 'master' into master-ghc8107 2023-11-17 11:47:52 +00:00
Evgeny Poberezkin 80ddb50e1c Merge branch 'master-ghc8107' into master-android 2023-11-16 18:55:17 +00:00
Evgeny Poberezkin f6e66f1c53 Merge branch 'master' into master-ghc8107 2023-11-16 18:13:02 +00:00
Evgeny Poberezkin 0c23ff9ae3 Merge branch 'master-ghc8107' into master-android 2023-11-11 13:57:32 +00:00
Evgeny Poberezkin 1570bc2b99 Merge branch 'master' into master-ghc8107 2023-11-11 13:56:53 +00:00
Evgeny Poberezkin 1e2104cabf Merge branch 'master-ghc8107' into master-android 2023-11-11 09:52:07 +00:00
Evgeny Poberezkin f3014f258d Merge branch 'master' into master-ghc8107 2023-11-11 09:51:42 +00:00
Evgeny Poberezkin f0991cc0ba Merge branch 'master-ghc8107' into master-android 2023-11-10 21:22:19 +00:00
Evgeny Poberezkin 74b78a8d7b Merge branch 'master' into master-ghc8107 2023-11-10 21:11:08 +00:00
Evgeny Poberezkin 82cd70a75c Merge branch 'master-ghc8107' into master-android 2023-11-10 21:08:11 +00:00
Evgeny Poberezkin fe4eb7b5af Merge branch 'master' into master-ghc8107 without changes, to skip update for ghc 9.6.3 2023-11-10 21:04:20 +00:00
Evgeny Poberezkin c459e71d02 Merge branch 'master-ghc8107' into master-android 2023-11-06 11:26:40 +00:00
Evgeny Poberezkin 2516d5a393 Merge branch 'master' into master-ghc8107 2023-11-06 11:26:22 +00:00
spaced4ndy 477d98d75a Merge branch 'master-ghc8107' into master-android 2023-11-06 11:42:39 +04:00
spaced4ndy 4253cd7fb9 Merge branch 'master' into master-ghc8107 2023-11-06 11:41:55 +04:00
Evgeny Poberezkin ca78958667 Merge branch 'master-ghc8107' into master-android 2023-11-04 13:39:35 +00:00
Evgeny Poberezkin 1f5b80d560 fix for ghc8107 2023-11-04 13:37:25 +00:00
Evgeny Poberezkin 2de111e76c Merge branch 'master' into master-ghc8107 2023-11-04 13:02:08 +00:00
spaced4ndy 8343285d93 Merge branch 'master-ghc8107' into master-android 2023-10-30 21:01:16 +04:00
spaced4ndy 5dbe2b2745 Merge branch 'master' into master-ghc8107 2023-10-30 21:00:11 +04:00
Evgeny Poberezkin fb9485190d Merge branch 'master-ghc8107' into master-android 2023-10-29 18:27:00 +00:00
Evgeny Poberezkin 6881600e06 Merge branch 'master' into master-ghc8107 2023-10-29 18:24:13 +00:00
spaced4ndy 9ed723bafa Merge branch 'master-ghc8107' into master-android 2023-10-25 10:48:37 +04:00
spaced4ndy 9ded1c9821 Merge branch 'master' into master-ghc8107 2023-10-25 10:47:35 +04:00
spaced4ndy bb374c68b1 Merge branch 'master-ghc8107' into master-android 2023-10-24 18:14:22 +04:00
spaced4ndy c3e82a6a4e Merge branch 'master' into master-ghc8107 2023-10-24 18:13:56 +04:00
spaced4ndy 7c12e82042 Merge branch 'master-ghc8107' into master-android 2023-10-24 17:41:19 +04:00
spaced4ndy e7e66ff873 Merge branch 'master' into master-ghc8107 2023-10-24 17:40:55 +04:00
Evgeny Poberezkin c4d7e5307c Merge branch 'master-ghc8107' into master-android 2023-10-22 15:35:55 +01:00
Evgeny Poberezkin d6b9a45a39 Merge branch 'master' into master-ghc8107 2023-10-22 15:10:33 +01:00
Evgeny Poberezkin 7fd3b4d6ba Merge branch 'master-ghc8107' into master-android 2023-10-18 22:45:11 +01:00
Evgeny Poberezkin 4004aafbc5 Merge branch 'master' into master-ghc8107 2023-10-18 22:44:27 +01:00
spaced4ndy 95008eeeaf Merge branch 'master-ghc8107' into master-android 2023-10-16 20:05:49 +04:00
spaced4ndy c7a8992043 core: fix compilation for ghc 8.10.7 2023-10-16 20:05:13 +04:00
spaced4ndy ea2b5f2ccf Merge branch 'master-ghc8107' into master-android 2023-10-16 19:28:48 +04:00
spaced4ndy ed9f277421 Merge branch 'master' into master-ghc8107 2023-10-16 19:28:06 +04:00
Evgeny Poberezkin 5c14c3b349 Merge branch 'master-ghc8107' into master-android 2023-10-15 18:54:16 +01:00
Evgeny Poberezkin d8fb31f167 Merge branch 'master' into master-ghc8107 2023-10-15 18:53:23 +01:00
Evgeny Poberezkin 02db38ffd3 Merge branch 'master-ghc8107' into master-android 2023-10-11 21:58:16 +01:00
Evgeny Poberezkin 7692195bfa core: fix for ghc 8.10.7 2023-10-11 21:57:53 +01:00
Evgeny Poberezkin c435cbdc7b Merge branch 'master-ghc8107' into master-android 2023-10-11 21:28:43 +01:00
Evgeny Poberezkin effc281271 Merge branch 'master' into master-ghc8107 2023-10-11 21:27:21 +01:00
spaced4ndy 41eb2e5689 Merge branch 'master-ghc8107' into master-android 2023-10-11 13:22:30 +04:00
spaced4ndy 67d74a0a27 Merge branch 'master' into master-ghc8107 2023-10-11 13:21:46 +04:00
Evgeny Poberezkin f66405e79b Merge branch 'master-ghc8107' into master-android 2023-10-09 17:31:56 +01:00
Evgeny Poberezkin 74d186af16 Merge branch 'master' into master-ghc8107 2023-10-09 17:31:27 +01:00
Evgeny Poberezkin 187fef0c5a Merge branch 'master-ghc8107' into master-android 2023-10-09 14:05:21 +01:00
Evgeny Poberezkin 4782cab507 Merge branch 'master' into master-ghc8107 2023-10-09 14:05:04 +01:00
Evgeny Poberezkin bcbee67709 Merge branch 'master-ghc8107' into master-android 2023-10-08 08:38:40 +01:00
Evgeny Poberezkin 2501cbe55d Merge branch 'master' into master-ghc8107 2023-10-08 08:38:02 +01:00
Evgeny Poberezkin 2bd049db87 Merge branch 'master-ghc8107' into master-android 2023-10-07 21:10:22 +01:00
Evgeny Poberezkin 6b8b9ab4fd Merge branch 'master' into master-ghc8107 2023-10-07 19:06:38 +01:00
Evgeny Poberezkin 30db24265e Merge branch 'master-ghc8107' into master-android 2023-10-02 23:04:44 +01:00
Evgeny Poberezkin 316d605899 Merge branch 'master' into master-ghc8107 2023-10-02 23:04:13 +01:00
Evgeny Poberezkin b4257f7767 Merge branch 'master-ghc8107' into master-android 2023-10-01 13:21:50 +01:00
Evgeny Poberezkin a3f2d5c919 Merge branch 'master' into master-ghc8107 2023-10-01 13:20:06 +01:00
Evgeny Poberezkin cf46469cd5 Merge branch 'master-ghc8107' into master-android 2023-10-01 11:19:50 +01:00
Evgeny Poberezkin 0312fde818 Merge branch 'master' into master-ghc8107 2023-10-01 11:19:27 +01:00
Evgeny Poberezkin 9defa44f0c Merge branch 'master-ghc8107' into master-android 2023-09-29 13:15:23 +01:00
Evgeny Poberezkin 915b53054c Merge branch 'master' into master-ghc8107 2023-09-29 13:14:57 +01:00
Evgeny Poberezkin f81557b4fd Merge branch 'master-ghc8107' into master-android 2023-09-27 22:10:06 +01:00
Evgeny Poberezkin e273bd1239 Merge branch 'master' into master-ghc8107 2023-09-27 22:04:00 +01:00
spaced4ndy a63caf4640 Merge branch 'master-ghc8107' into master-android 2023-09-27 20:38:36 +04:00
spaced4ndy e7f0234134 Merge branch 'master' into master-ghc8107 2023-09-27 20:11:39 +04:00
Evgeny Poberezkin 340552321e Merge branch 'master-ghc8107' into master-android 2023-09-27 16:07:24 +01:00
Evgeny Poberezkin 98a3fc214d Merge branch 'master' into master-ghc8107 2023-09-27 16:04:25 +01:00
Evgeny Poberezkin 6a578cfe3c Merge branch 'master-ghc8107' into master-android 2023-09-25 16:53:04 +01:00
Evgeny Poberezkin dacc075fe8 Merge branch 'master' into master-ghc8107 2023-09-25 16:52:33 +01:00
spaced4ndy 55418e2bc0 Merge branch 'master-ghc8107' into master-android 2023-09-25 17:43:59 +04:00
spaced4ndy f2b5c0f3a8 Merge branch 'master' into master-ghc8107 2023-09-25 17:43:37 +04:00
spaced4ndy 5ebdf5dba9 Merge branch 'master-ghc8107' into master-android 2023-09-25 17:07:14 +04:00
spaced4ndy 8e045764df Merge branch 'master' into master-ghc8107 2023-09-25 16:40:08 +04:00
Evgeny Poberezkin 503d3d77e6 Merge branch 'master' into master-ghc8107 2023-09-23 08:47:28 +01:00
Evgeny Poberezkin 81bd7d97c5 Merge branch 'master' into master-ghc8107 2023-09-22 17:21:54 +01:00
Evgeny Poberezkin 8f57925067 compatibility with GHC 8.10.7 2023-09-22 14:01:25 +01:00
Evgeny Poberezkin 9bf99db82e Merge branch 'master' into master-ghc8107 2023-09-22 13:46:50 +01:00
Evgeny Poberezkin 5615cdbf1a Merge branch 'master' into master-android 2023-09-21 17:04:47 +01:00
Evgeny Poberezkin d802ae0058 Merge branch 'master' into master-android 2023-09-21 12:06:10 +01:00
Evgeny Poberezkin 8f2278198c Merge branch 'master' into master-android 2023-09-20 14:55:25 +01:00
spaced4ndy 10937a5a4e Merge branch 'master' into master-android 2023-09-20 17:36:53 +04:00
Evgeny Poberezkin 6aff6e9804 Merge branch 'master-ghc9' into master-ghc8107 2023-09-18 21:56:35 +01:00
Evgeny Poberezkin 95477cae7e core: use commit from simplexmq branch master-ghc8107 2023-09-18 21:45:50 +01:00
729 changed files with 12449 additions and 39672 deletions
+1 -11
View File
@@ -5,22 +5,12 @@ 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:
-1
View File
@@ -61,7 +61,6 @@ 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/
+1 -1
View File
@@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat # Compile simplex-chat
RUN cabal update RUN cabal update
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' RUN cabal build exe:simplex-chat
# Strip the binary from debug symbols to reduce size # Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \ RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
+2 -4
View File
@@ -25,9 +25,7 @@ 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.
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). 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 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
@@ -174,4 +172,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 October 14, 2024 Updated April 24, 2024
+29 -29
View File
@@ -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 [support us with donations](#please-support-us-with-your-donations). 5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-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=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) 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)
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-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). [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code. 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.
## Please support us with your donations ## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat! Huge thank you to everybody who donated to SimpleX Chat!
@@ -233,12 +233,12 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates: Recent and important updates:
[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)
[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) [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)
[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)
[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)
[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)
[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md) [Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
@@ -247,6 +247,10 @@ 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).
@@ -294,27 +298,25 @@ 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. [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. 4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
5. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well). 5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
6. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks. 6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
7. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed. 7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
8. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448. 8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
9. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key. 9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
10. 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). 10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
11. 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. 11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
12. 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. 12. Manual messaging queue rotations to move conversation to another SMP relay.
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. 13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
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. 14. Local files encryption.
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. 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). 1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time. 2. Post-quantum resistant key exchange in double ratchet protocol.
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. 3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party. 4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
5. Reproducible builds this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
## For developers ## For developers
@@ -403,9 +405,7 @@ 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 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 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 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.
+24
View File
@@ -15,11 +15,35 @@ 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() 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)")
@@ -1,12 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.2.svg",
"idiom": "universal"
}
]
}
@@ -1,227 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 24 KiB

@@ -1,12 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.wide.svg",
"idiom": "universal"
}
]
}
@@ -1,218 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 22 KiB

+10 -18
View File
@@ -13,7 +13,6 @@ struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared @ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared @ObservedObject var callController = CallController.shared
@ObservedObject var appSheetState = AppSheetState.shared
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@EnvironmentObject var sceneDelegate: SceneDelegate @EnvironmentObject var sceneDelegate: SceneDelegate
@@ -30,14 +29,14 @@ struct ContentView: View {
@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_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 = 40 private let callTopPadding: CGFloat = 50
private enum ChatListActionSheet: Identifiable { private enum ChatListActionSheet: Identifiable {
case planAndConnectSheet(sheet: PlanAndConnectActionSheet) case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
@@ -87,7 +86,7 @@ struct ContentView: View {
callView(call) callView(call)
} }
if chatListUserPickerSheet == nil, let la = chatModel.laRequest { if !showSettings, 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
@@ -110,6 +109,9 @@ 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 }
@@ -149,12 +151,12 @@ struct ContentView: View {
} }
} }
.onAppear { .onAppear {
reactOnDarkThemeChanges(systemInDarkThemeCurrently) reactOnDarkThemeChanges()
} }
.onChange(of: colorScheme) { scheme in .onChange(of: colorScheme) { scheme in
// It's needed to update UI colors when iOS wants to make screenshot after going to background, // 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 // so when a user changes his global theme from dark to light or back, the app will adapt to it
reactOnDarkThemeChanges(scheme == .dark) reactOnDarkThemeChanges()
} }
.onChange(of: theme.name) { _ in .onChange(of: theme.name) { _ in
ThemeManager.adjustWindowStyle() ThemeManager.adjustWindowStyle()
@@ -205,7 +207,7 @@ struct ContentView: View {
CallDuration(call: call) CallDuration(call: call)
} }
.padding(.horizontal) .padding(.horizontal)
.frame(height: callTopPadding) .frame(height: callTopPadding - 10)
.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
@@ -251,8 +253,7 @@ struct ContentView: View {
private func mainView() -> some View { private func mainView() -> some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet) ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
.redacted(reason: appSheetState.redactionReasons(protectScreen))
.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
@@ -299,21 +300,12 @@ 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 {
let activeCall = chatModel.activeCall
// This line works when a user clicks on a video button in CallKit UI while in call.
// 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") logger.debug("callToRecentContact: schedule call")
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
CallController.shared.startCall(contact, mediaType) CallController.shared.startCall(contact, mediaType)
} }
} }
} }
}
private func unlockedRecently() -> Bool { private func unlockedRecently() -> Bool {
if let lastSuccessfulUnlock = lastSuccessfulUnlock { if let lastSuccessfulUnlock = lastSuccessfulUnlock {
+92 -100
View File
@@ -54,81 +54,12 @@ class ItemsModel: ObservableObject {
willSet { publisher.send() } 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() { init() {
publisher publisher
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) .throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() } .sink { self.objectWillChange.send() }
.store(in: &bag) .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 {
@@ -147,13 +78,14 @@ final class ChatModel: ObservableObject {
@Published var chatDbEncrypted: Bool? @Published var chatDbEncrypted: Bool?
@Published var chatDbStatus: DBMigrationResult? @Published var chatDbStatus: DBMigrationResult?
@Published var ctrlInitInProgress: Bool = false @Published var ctrlInitInProgress: Bool = false
@Published var notificationResponse: UNNotificationResponse?
// local authentication // local authentication
@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 private(set) var chats: [Chat] = [] @Published 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?
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:] var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@@ -192,6 +124,9 @@ 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> = [:]
@@ -365,8 +300,25 @@ final class ChatModel: ObservableObject {
} }
} }
func updateChats(_ newChats: [ChatData]) { func updateChats(with newChats: [ChatData]) {
chats = newChats.map { Chat($0) } for i in 0..<newChats.count {
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() popChatCollector.clear()
} }
@@ -376,12 +328,6 @@ 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 {
@@ -436,17 +382,19 @@ final class ChatModel: ObservableObject {
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) {
withConditionalAnimation {
_updateChatItem(at: i, with: cItem) _updateChatItem(at: i, with: cItem)
ChatItemDummyModel.shared.sendUpdate() }
return false return false
} else { } else {
withConditionalAnimation(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
} }
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate() }
return true return true
} }
@@ -527,7 +475,7 @@ final class ChatModel: ObservableObject {
} }
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) { func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
guard var current = currentUser, current.uiThemes != uiThemes else { return } guard var current = currentUser else { return }
current.uiThemes = uiThemes current.uiThemes = uiThemes
let i = users.firstIndex(where: { $0.user.userId == current.userId }) let i = users.firstIndex(where: { $0.user.userId == current.userId })
if let i { if let i {
@@ -563,7 +511,6 @@ final class ChatModel: ObservableObject {
// 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
@@ -580,12 +527,6 @@ final class ChatModel: ObservableObject {
} }
} }
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) {
@@ -604,7 +545,6 @@ 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)
} }
} }
} }
@@ -634,15 +574,19 @@ final class ChatModel: ObservableObject {
} }
} }
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) { func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
if self.chatId == cInfo.id { if chatId == cInfo.id,
for itemId in itemIds { let itemIndex = getChatItemIndex(cItem),
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) { im.reversedChatItems[itemIndex].isRcvNew {
markChatItemRead_(i) await MainActor.run {
withTransaction(Transaction()) {
// update current chat
markChatItemRead_(itemIndex)
// update preview
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
} }
} }
} }
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
} }
private let unreadCollector = UnreadCollector() private let unreadCollector = UnreadCollector()
@@ -668,10 +612,9 @@ final class ChatModel: ObservableObject {
} }
func changeUnreadCounter(_ chatId: ChatId, by count: Int) { func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
if chatId == ChatModel.shared.chatId { DispatchQueue.main.async {
ChatView.FloatingButtonModel.shared.totalUnread += count
}
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
}
subject.send() subject.send()
} }
} }
@@ -893,6 +836,49 @@ 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 < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) {
totalBelow += 1
if im.reversedChatItems[i].isRcvNew {
unreadBelow += 1
}
i += 1
}
return UnreadChatItemCounts(
// TODO these thresholds account for the fact that items are still "visible" while
// covered by compose area, they should be replaced with the actual height in pixels below the screen.
isNearBottom: totalBelow < 15,
isReallyNearBottom: totalBelow < 2,
unreadBelow: unreadBelow
)
}
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
let maxIx = im.reversedChatItems.count - 1
var i = 0
let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) }
while i < maxIx && !inView(i) { i += 1 }
while i < maxIx && inView(i) { i += 1 }
return im.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 {
@@ -905,6 +891,12 @@ struct NTFContactRequest {
var chatId: String var chatId: String
} }
struct UnreadChatItemCounts: Equatable {
var isNearBottom: Bool
var isReallyNearBottom: Bool
var unreadBelow: Int
}
final class Chat: ObservableObject, Identifiable, ChatLike { final class Chat: ObservableObject, Identifiable, ChatLike {
@Published var chatInfo: ChatInfo @Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem] @Published var chatItems: [ChatItem]
+11 -32
View File
@@ -29,33 +29,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
private var granted = false private var granted = false
private var prevNtfTime: Dictionary<ChatId, Date> = [:] private var prevNtfTime: Dictionary<ChatId, Date> = [:]
override init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
// Handle notification when app is in background // Handle notification when app is in background
func userNotificationCenter(_ center: UNUserNotificationCenter, func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
withCompletionHandler handler: () -> Void) { withCompletionHandler handler: () -> Void) {
logger.debug("NtfManager.userNotificationCenter: didReceive") logger.debug("NtfManager.userNotificationCenter: didReceive")
if appStateGroupDefault.get() == .active { let content = response.notification.request.content
processNotificationResponse(response)
} else {
logger.debug("NtfManager.userNotificationCenter: remember response in model")
ChatModel.shared.notificationResponse = response
}
handler()
}
func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
let chatModel = ChatModel.shared let chatModel = ChatModel.shared
let content = ntfResponse.notification.request.content let action = response.actionIdentifier
let action = ntfResponse.actionIdentifier logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
logger.debug("NtfManager.processNotificationResponse: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
if let userId = content.userInfo["userId"] as? Int64, if let userId = content.userInfo["userId"] as? Int64,
userId != chatModel.currentUser?.userId { userId != chatModel.currentUser?.userId {
logger.debug("NtfManager.processNotificationResponse changeActiveUser")
changeActiveUser(userId, viewPwd: nil) changeActiveUser(userId, viewPwd: nil)
} }
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito), if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
@@ -73,10 +57,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
chatModel.ntfCallInvitationAction = (chatId, ntfAction) chatModel.ntfCallInvitationAction = (chatId, ntfAction)
} }
} else { } else {
if let chatId = content.targetContentIdentifier { chatModel.chatId = content.targetContentIdentifier
ItemsModel.shared.loadOpenChat(chatId)
}
} }
handler()
} }
private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? { private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? {
@@ -91,6 +74,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
return nil return nil
} }
// Handle notification when the app is in foreground // Handle notification when the app is in foreground
func userNotificationCenter(_ center: UNUserNotificationCenter, func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification, willPresent notification: UNNotification,
@@ -199,12 +183,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
actions: [], actions: [],
intentIdentifiers: [], intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification") hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
),
UNNotificationCategory(
identifier: ntfCategoryManyEvents,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New events", comment: "notification")
) )
]) ])
} }
@@ -230,28 +208,29 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
} }
} }
} }
center.delegate = self
} }
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) { func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest") logger.debug("NtfManager.notifyContactRequest")
addNotification(createContactRequestNtf(user, contactRequest, 0)) addNotification(createContactRequestNtf(user, contactRequest))
} }
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) { func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected") logger.debug("NtfManager.notifyContactConnected")
addNotification(createContactConnectedNtf(user, contact, 0)) addNotification(createContactConnectedNtf(user, contact))
} }
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) { func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived") logger.debug("NtfManager.notifyMessageReceived")
if cInfo.ntfsEnabled { if cInfo.ntfsEnabled {
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0)) addNotification(createMessageReceivedNtf(user, cInfo, cItem))
} }
} }
func notifyCallInvitation(_ invitation: RcvCallInvitation) { func notifyCallInvitation(_ invitation: RcvCallInvitation) {
logger.debug("NtfManager.notifyCallInvitation") logger.debug("NtfManager.notifyCallInvitation")
addNotification(createCallInvitationNtf(invitation, 0)) addNotification(createCallInvitationNtf(invitation))
} }
func setNtfBadgeCount(_ count: Int) { func setNtfBadgeCount(_ count: Int) {
+128 -235
View File
@@ -112,9 +112,9 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
return resp return resp
} }
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse { func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) async -> ChatResponse {
await withCheckedContinuation { cont in await withCheckedContinuation { cont in
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log)) cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl))
} }
} }
@@ -320,8 +320,8 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
let loadItemsPerPage = 50 let loadItemsPerPage = 50
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat { func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search)) let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
if case let .apiChat(_, chat) = r { return Chat.init(chat) } if case let .apiChat(_, chat) = r { return Chat.init(chat) }
throw r throw r
} }
@@ -332,20 +332,22 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
throw r throw r
} }
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { func loadChat(chat: Chat, search: String = "") {
do { do {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
let m = ChatModel.shared let m = ChatModel.shared
let im = ItemsModel.shared let im = ItemsModel.shared
m.chatItemStatuses = [:] m.chatItemStatuses = [:]
if clearItems { im.reversedChatItems = []
await MainActor.run { im.reversedChatItems = [] } let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
} if case let .direct(contact) = chat.chatInfo, !cInfo.chatDeleted, chat.chatInfo.chatDeleted {
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) var updatedContact = contact
await MainActor.run { updatedContact.chatDeleted = false
im.reversedChatItems = chat.chatItems.reversed() m.updateContact(updatedContact)
} else {
m.updateChatInfo(chat.chatInfo) m.updateChatInfo(chat.chatInfo)
} }
im.reversedChatItems = chat.chatItems.reversed()
} catch let error { } catch let error {
logger.error("loadChat error: \(responseError(error))") logger.error("loadChat error: \(responseError(error))")
} }
@@ -357,23 +359,17 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
throw r throw r
} }
func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) { func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? {
let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds)) let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl)
if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) }
throw r
}
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
} }
func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages) let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl)
return await processSendMessageCmd(toChatType: type, cmd: cmd) return await processSendMessageCmd(toChatType: type, cmd: cmd)
} }
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? { private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? {
let chatModel = ChatModel.shared let chatModel = ChatModel.shared
let r: ChatResponse let r: ChatResponse
if toChatType == .direct { if toChatType == .direct {
@@ -386,13 +382,10 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
} }
}) })
r = await chatSendCmd(cmd, bgTask: false) r = await chatSendCmd(cmd, bgTask: false)
if case let .newChatItems(_, aChatItems) = r { if case let .newChatItem(_, aChatItem) = r {
let cItems = aChatItems.map { $0.chatItem } cItem = aChatItem.chatItem
if let cItemLast = cItems.last { chatModel.messageDelivery[aChatItem.chatItem.id] = endTask
cItem = cItemLast return cItem
chatModel.messageDelivery[cItemLast.id] = endTask
}
return cItems
} }
if let networkErrorAlert = networkErrorAlert(r) { if let networkErrorAlert = networkErrorAlert(r) {
AlertManager.shared.showAlert(networkErrorAlert) AlertManager.shared.showAlert(networkErrorAlert)
@@ -403,18 +396,18 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
return nil return nil
} else { } else {
r = await chatSendCmd(cmd, bgDelay: msgDelay) r = await chatSendCmd(cmd, bgDelay: msgDelay)
if case let .newChatItems(_, aChatItems) = r { if case let .newChatItem(_, aChatItem) = r {
return aChatItems.map { $0.chatItem } return aChatItem.chatItem
} }
sendMessageErrorAlert(r) sendMessageErrorAlert(r)
return nil return nil
} }
} }
func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? { func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) async -> ChatItem? {
let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages)) let r = await chatSendCmd(.apiCreateChatItem(noteFolderId: noteFolderId, file: file, msg: msg))
if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem }
createChatItemsErrorAlert(r) createChatItemErrorAlert(r)
return nil return nil
} }
@@ -426,8 +419,8 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
) )
} }
private func createChatItemsErrorAlert(_ r: ChatResponse) { private func createChatItemErrorAlert(_ r: ChatResponse) {
logger.error("apiCreateChatItems error: \(String(describing: r))") logger.error("apiCreateChatItem error: \(String(describing: r))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Error creating message", title: "Error creating message",
message: "Error: \(responseError(r))" message: "Error: \(responseError(r))"
@@ -462,7 +455,7 @@ func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String
let r = chatSendCmdSync(.apiGetNtfToken) let r = chatSendCmdSync(.apiGetNtfToken)
switch r { switch r {
case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer) case let .ntfToken(token, status, ntfMode, ntfServer): return (token, status, ntfMode, ntfServer)
case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil) case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off, nil)
default: default:
logger.debug("apiGetNtfToken response: \(String(describing: r))") logger.debug("apiGetNtfToken response: \(String(describing: r))")
return (nil, nil, .off, nil) return (nil, nil, .off, nil)
@@ -585,25 +578,19 @@ func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profi
throw r throw r
} }
func apiGroupMemberInfoSync(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) { func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) } if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
throw r throw r
} }
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, ConnectionStats?) { func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
throw r
}
func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) {
let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId)) let r = await chatSendCmd(.apiContactQueueInfo(contactId: contactId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) } if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
throw r throw r
} }
func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo) { func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, QueueInfo) {
let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId)) let r = await chatSendCmd(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) } if case let .queueInfo(_, rcvMsgInfo, queueInfo) = r { return (rcvMsgInfo, queueInfo) }
throw r throw r
@@ -651,8 +638,8 @@ func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
throw r throw r
} }
func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, String) { func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) {
let r = await chatSendCmd(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId)) let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberCode(_, _, member, connectionCode) = r { return (member, connectionCode) } if case let .groupMemberCode(_, _, member, connectionCode) = r { return (member, connectionCode) }
throw r throw r
} }
@@ -688,13 +675,6 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
throw r throw r
} }
func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
throw r
}
func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
let userId = try currentUserId("apiConnectPlan") let userId = try currentUserId("apiConnectPlan")
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
@@ -727,7 +707,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
return ((.contact, connection), nil) return ((.contact, connection), nil)
case let .contactAlreadyExists(_, contact): case let .contactAlreadyExists(_, contact):
if let c = m.getContactChat(contact.contactId) { if let c = m.getContactChat(contact.contactId) {
ItemsModel.shared.loadOpenChat(c.id) await MainActor.run { m.chatId = c.id }
} }
let alert = contactAlreadyExistsAlert(contact) let alert = contactAlreadyExistsAlert(contact)
return (nil, alert) return (nil, alert)
@@ -743,12 +723,6 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
) )
return (nil, alert) return (nil, alert)
case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))):
let alert = mkAlert(
title: "Undelivered messages",
message: "The connection reached the limit of undelivered messages, your contact may be offline."
)
return (nil, alert)
case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))): case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))):
if internalErr == "SEUniqueID" { if internalErr == "SEUniqueID" {
let alert = mkAlert( let alert = mkAlert(
@@ -1018,10 +992,6 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange)) try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
} }
func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat)) try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
} }
@@ -1057,122 +1027,77 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF
} }
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async { func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async {
await receiveFiles( if let chatItem = await apiReceiveFile(
user: user,
fileIds: [fileId],
userApprovedRelays: userApprovedRelays,
auto: auto
)
}
func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async {
var fileIdsToApprove = [Int64]()
var srvsToApprove = Set<String>()
var otherFileErrs = [ChatResponse]()
for fileId in fileIds {
let r = await chatSendCmd(
.receiveFile(
fileId: fileId, fileId: fileId,
userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(), userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
encrypted: privacyEncryptLocalFilesGroupDefault.get(), encrypted: privacyEncryptLocalFilesGroupDefault.get(),
inline: nil auto: auto
) ) {
)
switch r {
case let .rcvFileAccepted(_, chatItem):
await chatItemSimpleUpdate(user, chatItem) await chatItemSimpleUpdate(user, chatItem)
default:
if let chatError = chatError(r) {
switch chatError {
case let .fileNotApproved(fileId, unknownServers):
fileIdsToApprove.append(fileId)
srvsToApprove.formUnion(unknownServers)
default:
otherFileErrs.append(r)
}
}
}
} }
}
func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
let r = await chatSendCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
let am = AlertManager.shared
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
if case .rcvFileAcceptedSndCancelled = r {
logger.debug("apiReceiveFile error: sender cancelled file transfer")
if !auto { if !auto {
let otherErrsStr = if otherFileErrs.isEmpty { am.showAlertMsg(
"" title: "Cannot receive file",
} else if otherFileErrs.count == 1 { message: "Sender cancelled file transfer."
"\(otherFileErrs[0])" )
} else if otherFileErrs.count == 2 {
"\(otherFileErrs[0])\n\(otherFileErrs[1])"
} else {
"\(otherFileErrs[0])\n\(otherFileErrs[1])\nand \(otherFileErrs.count - 2) other error(s)"
} }
} else if let networkErrorAlert = networkErrorAlert(r) {
// If there are not approved files, alert is shown the same way both in case of singular and plural files reception logger.error("apiReceiveFile network error: \(String(describing: r))")
if !fileIdsToApprove.isEmpty { if !auto {
let srvs = srvsToApprove am.showAlert(networkErrorAlert)
.map { s in }
} else {
switch chatError(r) {
case .fileCancelled:
logger.debug("apiReceiveFile ignoring fileCancelled error")
case .fileAlreadyReceiving:
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
case let .fileNotApproved(fileId, unknownServers):
logger.debug("apiReceiveFile fileNotApproved error")
if !auto {
let srvs = unknownServers.map { s in
if let srv = parseServerAddress(s), !srv.hostnames.isEmpty { if let srv = parseServerAddress(s), !srv.hostnames.isEmpty {
srv.hostnames[0] srv.hostnames[0]
} else { } else {
serverHost(s) serverHost(s)
} }
} }
.sorted() am.showAlert(Alert(
.joined(separator: ", ") title: Text("Unknown servers!"),
let fIds = fileIdsToApprove message: Text("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs.sorted().joined(separator: ", "))."),
await MainActor.run { primaryButton: .default(
showAlert( Text("Download"),
title: NSLocalizedString("Unknown servers!", comment: "alert title"), action: {
message: (
String.localizedStringWithFormat(NSLocalizedString("Without Tor or VPN, your IP address will be visible to these XFTP relays: %@.", comment: "alert message"), srvs) +
(otherErrsStr != "" ? "\n\n" + String.localizedStringWithFormat(NSLocalizedString("Other file errors:\n%@", comment: "alert message"), otherErrsStr) : "")
),
buttonTitle: NSLocalizedString("Download", comment: "alert button"),
buttonAction: {
Task { Task {
logger.debug("apiReceiveFile fileNotApproved alert - in Task") logger.debug("apiReceiveFile fileNotApproved alert - in Task")
if let user = ChatModel.shared.currentUser { if let user = ChatModel.shared.currentUser {
await receiveFiles(user: user, fileIds: fIds, userApprovedRelays: true) await receiveFile(user: user, fileId: fileId, userApprovedRelays: true)
} }
} }
},
cancelButton: true
)
} }
} else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it ),
let errorResponse = otherFileErrs.first! secondaryButton: .cancel()
switch errorResponse { ))
case let .rcvFileAcceptedSndCancelled(_, rcvFileTransfer):
logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)")
await MainActor.run {
showAlert(
NSLocalizedString("Cannot receive file", comment: "alert title"),
message: NSLocalizedString("Sender cancelled file transfer.", comment: "alert message")
)
} }
default: default:
if let chatError = chatError(errorResponse) { logger.error("apiReceiveFile error: \(String(describing: r))")
switch chatError { if !auto {
case .fileCancelled, .fileAlreadyReceiving: am.showAlertMsg(
logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") title: "Error receiving file",
default: message: "Error: \(responseError(r))"
await MainActor.run {
showAlert(
NSLocalizedString("Error receiving file", comment: "alert title"),
message: responseError(errorResponse)
)
}
}
}
}
} else if otherFileErrs.count > 1 { // If there are multiple other errors, we show general alert
await MainActor.run {
showAlert(
NSLocalizedString("Error receiving file", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("File errors:\n%@", comment: "alert message"), otherErrsStr)
) )
} }
} }
} }
return nil
} }
func cancelFile(user: User, fileId: Int64) async { func cancelFile(user: User, fileId: Int64) async {
@@ -1246,12 +1171,12 @@ func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) a
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
await MainActor.run { await MainActor.run {
ChatModel.shared.replaceChat(contactRequest.id, chat) ChatModel.shared.replaceChat(contactRequest.id, chat)
NetworkModel.shared.setContactNetworkStatus(contact, .connected) ChatModel.shared.setContactNetworkStatus(contact, .connected)
} }
if contact.sndReady { if contact.sndReady {
DispatchQueue.main.async { DispatchQueue.main.async {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(chat.id) ChatModel.shared.chatId = chat.id
} }
} }
} }
@@ -1295,18 +1220,12 @@ func apiEndCall(_ contact: Contact) async throws {
try await sendCommandOkResp(.apiEndCall(contact: contact)) try await sendCommandOkResp(.apiEndCall(contact: contact))
} }
func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] { func apiGetCallInvitations() throws -> [RcvCallInvitation] {
let r = chatSendCmdSync(.apiGetCallInvitations) let r = chatSendCmdSync(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs } if case let .callInvitations(invs) = r { return invs }
throw r throw r
} }
func apiGetCallInvitations() async throws -> [RcvCallInvitation] {
let r = await chatSendCmd(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
throw r
}
func apiCallStatus(_ contact: Contact, _ status: String) async throws { func apiCallStatus(_ contact: Contact, _ status: String) async throws {
if let callStatus = WebRTCCallStatus.init(rawValue: status) { if let callStatus = WebRTCCallStatus.init(rawValue: status) {
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus)) try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
@@ -1354,23 +1273,11 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do { do {
logger.debug("apiMarkChatItemRead: \(cItem.id)")
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
DispatchQueue.main.async { await ChatModel.shared.markChatItemRead(cInfo, cItem)
ChatModel.shared.markChatItemsRead(cInfo, [cItem.id])
}
} catch { } catch {
logger.error("apiChatRead error: \(responseError(error))") logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
}
}
func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async {
do {
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
DispatchQueue.main.async {
ChatModel.shared.markChatItemsRead(cInfo, itemIds)
}
} catch {
logger.error("apiChatItemsRead error: \(responseError(error))")
} }
} }
@@ -1515,9 +1422,9 @@ func apiGetVersion() throws -> CoreVersionInfo {
throw r throw r
} }
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) { func getAgentSubsTotal() throws -> (SMPServerSubs, Bool) {
let userId = try currentUserId("getAgentSubsTotal") let userId = try currentUserId("getAgentSubsTotal")
let r = await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false) let r = chatSendCmdSync(.getAgentSubsTotal(userId: userId), log: false)
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) } if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))") logger.error("getAgentSubsTotal error: \(String(describing: r))")
throw r throw r
@@ -1612,7 +1519,7 @@ func startChat(refreshInvitations: Bool = true) throws {
try getUserChatData() try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers()) NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
if (refreshInvitations) { if (refreshInvitations) {
Task { try await refreshCallInvitations() } try refreshCallInvitations()
} }
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken() (m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
_ = try apiStartChat() _ = try apiStartChat()
@@ -1686,7 +1593,8 @@ func getUserChatData() throws {
m.userAddress = try apiGetUserAddress() m.userAddress = try apiGetUserAddress()
m.chatItemTTL = try getChatItemTTL() m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats() let chats = try apiGetChats()
m.updateChats(chats) m.chats = chats.map { Chat.init($0) }
m.popChatCollector.clear()
} }
private func getUserChatDataAsync() async throws { private func getUserChatDataAsync() async throws {
@@ -1698,12 +1606,14 @@ private func getUserChatDataAsync() async throws {
await MainActor.run { await MainActor.run {
m.userAddress = userAddress m.userAddress = userAddress
m.chatItemTTL = chatItemTTL m.chatItemTTL = chatItemTTL
m.updateChats(chats) m.chats = chats.map { Chat.init($0) }
m.popChatCollector.clear()
} }
} else { } else {
await MainActor.run { await MainActor.run {
m.userAddress = nil m.userAddress = nil
m.updateChats([]) m.chats = []
m.popChatCollector.clear()
} }
} }
} }
@@ -1753,7 +1663,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
await TerminalItems.shared.add(.resp(.now, res)) await TerminalItems.shared.add(.resp(.now, res))
} }
let m = ChatModel.shared let m = ChatModel.shared
let n = NetworkModel.shared
logger.debug("processReceivedMsg: \(res.responseType)") logger.debug("processReceivedMsg: \(res.responseType)")
switch res { switch res {
case let .contactDeletedByContact(user, contact): case let .contactDeletedByContact(user, contact):
@@ -1776,7 +1685,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
NtfManager.shared.notifyContactConnected(user, contact) NtfManager.shared.notifyContactConnected(user, contact)
} }
await MainActor.run { await MainActor.run {
n.setContactNetworkStatus(contact, .connected) m.setContactNetworkStatus(contact, .connected)
} }
case let .contactConnecting(user, contact): case let .contactConnecting(user, contact):
if active(user) && contact.directOrUsed { if active(user) && contact.directOrUsed {
@@ -1799,7 +1708,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
} }
await MainActor.run { await MainActor.run {
n.setContactNetworkStatus(contact, .connected) m.setContactNetworkStatus(contact, .connected)
} }
case let .receivedContactRequest(user, contactRequest): case let .receivedContactRequest(user, contactRequest):
if active(user) { if active(user) {
@@ -1833,7 +1742,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
if active(user) && m.hasChat(mergedContact.id) { if active(user) && m.hasChat(mergedContact.id) {
await MainActor.run { await MainActor.run {
if m.chatId == mergedContact.id { if m.chatId == mergedContact.id {
ItemsModel.shared.loadOpenChat(mergedContact.id) m.chatId = intoContact.id
} }
m.removeChat(mergedContact.id) m.removeChat(mergedContact.id)
} }
@@ -1841,35 +1750,39 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .networkStatus(status, connections): case let .networkStatus(status, connections):
// dispatch queue to synchronize access // dispatch queue to synchronize access
networkStatusesLock.sync { networkStatusesLock.sync {
var ns = n.networkStatuses var ns = m.networkStatuses
// slow loop is on the background thread // slow loop is on the background thread
for cId in connections { for cId in connections {
ns[cId] = status ns[cId] = status
} }
// fast model update is on the main thread // fast model update is on the main thread
DispatchQueue.main.sync { DispatchQueue.main.sync {
n.networkStatuses = ns m.networkStatuses = ns
} }
} }
case let .networkStatuses(_, statuses): () case let .networkStatuses(_, statuses): ()
// dispatch queue to synchronize access // dispatch queue to synchronize access
networkStatusesLock.sync { networkStatusesLock.sync {
var ns = n.networkStatuses var ns = m.networkStatuses
// slow loop is on the background thread // slow loop is on the background thread
for s in statuses { for s in statuses {
ns[s.agentConnId] = s.networkStatus ns[s.agentConnId] = s.networkStatus
} }
// fast model update is on the main thread // fast model update is on the main thread
DispatchQueue.main.sync { DispatchQueue.main.sync {
n.networkStatuses = ns m.networkStatuses = ns
} }
} }
case let .newChatItems(user, chatItems): case let .newChatItem(user, aChatItem):
for chatItem in chatItems { let cInfo = aChatItem.chatInfo
let cInfo = chatItem.chatInfo let cItem = aChatItem.chatItem
let cItem = chatItem.chatItem
await MainActor.run { await MainActor.run {
if active(user) { if active(user) {
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
m.updateContact(updatedContact)
}
m.addChatItem(cInfo, cItem) m.addChatItem(cInfo, cItem)
} else if cItem.isRcvNew && cInfo.ntfsEnabled { } else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user) m.increaseUnreadCounter(user: user)
@@ -1883,26 +1796,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
if cItem.showNotification { if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
} }
} case let .chatItemStatusUpdated(user, aChatItem):
case let .chatItemsStatusesUpdated(user, chatItems): let cInfo = aChatItem.chatInfo
for chatItem in chatItems { let cItem = aChatItem.chatItem
let cInfo = chatItem.chatInfo
let cItem = chatItem.chatItem
if !cItem.isDeletedContent && active(user) { if !cItem.isDeletedContent && active(user) {
await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) } await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
} }
if let endTask = m.messageDelivery[cItem.id] { if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus { switch cItem.meta.itemStatus {
case .sndNew: ()
case .sndSent: endTask() case .sndSent: endTask()
case .sndRcvd: endTask()
case .sndErrorAuth: endTask() case .sndErrorAuth: endTask()
case .sndError: endTask() case .sndError: endTask()
case .sndWarning: endTask() default: ()
case .rcvNew: ()
case .rcvRead: ()
case .invalid: ()
}
} }
} }
case let .chatItemUpdated(user, aChatItem): case let .chatItemUpdated(user, aChatItem):
@@ -2011,7 +1916,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
if let contact = memberContact { if let contact = memberContact {
await MainActor.run { await MainActor.run {
n.setContactNetworkStatus(contact, .connected) m.setContactNetworkStatus(contact, .connected)
} }
} }
case let .groupUpdated(user, toGroup): case let .groupUpdated(user, toGroup):
@@ -2096,6 +2001,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
await withCall(contact) { call in await withCall(contact) { call in
await MainActor.run { await MainActor.run {
call.callState = .offerReceived call.callState = .offerReceived
call.peerMedia = callType.media
call.sharedKey = sharedKey call.sharedKey = sharedKey
} }
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
@@ -2191,8 +2097,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.remoteCtrlSession = nil m.remoteCtrlSession = nil
dismissAllSheets() { dismissAllSheets() {
switch rcStopReason { switch rcStopReason {
case .disconnected:
()
case .connectionFailed(.errorAgent(.RCP(.identity))): case .connectionFailed(.errorAgent(.RCP(.identity))):
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Connection with desktop stopped", title: "Connection with desktop stopped",
@@ -2235,13 +2139,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
func switchToLocalSession() { func switchToLocalSession() {
let m = ChatModel.shared let m = ChatModel.shared
let n = NetworkModel.shared
m.remoteCtrlSession = nil m.remoteCtrlSession = nil
do { do {
m.users = try listUsers() m.users = try listUsers()
try getUserChatData() try getUserChatData()
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) } let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses) m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
} catch let error { } catch let error {
logger.debug("error updating chat data: \(responseError(error))") logger.debug("error updating chat data: \(responseError(error))")
} }
@@ -2264,11 +2167,9 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
} }
} }
func refreshCallInvitations() async throws { func refreshCallInvitations() throws {
let m = ChatModel.shared let m = ChatModel.shared
let callInvitations = try await apiGetCallInvitations() let callInvitations = try justRefreshCallInvitations()
await MainActor.run {
m.callInvitations = callsByChat(callInvitations)
if let (chatId, ntfAction) = m.ntfCallInvitationAction, if let (chatId, ntfAction) = m.ntfCallInvitationAction,
let invitation = m.callInvitations.removeValue(forKey: chatId) { let invitation = m.callInvitations.removeValue(forKey: chatId) {
m.ntfCallInvitationAction = nil m.ntfCallInvitationAction = nil
@@ -2276,30 +2177,22 @@ func refreshCallInvitations() async throws {
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) { } else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
activateCall(invitation) activateCall(invitation)
} }
}
} }
func justRefreshCallInvitations() async throws { func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
let callInvitations = try apiGetCallInvitationsSync() let m = ChatModel.shared
await MainActor.run { let callInvitations = try apiGetCallInvitations()
ChatModel.shared.callInvitations = callsByChat(callInvitations) m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
} return callInvitations
}
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) {
result, inv in result[inv.contact.id] = inv
}
} }
func activateCall(_ callInvitation: RcvCallInvitation) { func activateCall(_ callInvitation: RcvCallInvitation) {
if !callInvitation.user.showNotifications { return }
let m = ChatModel.shared let m = ChatModel.shared
logger.debug("reportNewIncomingCall activeCallUUID \(String(describing: m.activeCall?.callUUID)) invitationUUID \(String(describing: callInvitation.callUUID))")
if !callInvitation.user.showNotifications || m.activeCall?.callUUID == callInvitation.callUUID { return }
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
if let error = error { if let error = error {
DispatchQueue.main.async { DispatchQueue.main.async {
m.callInvitations[callInvitation.contact.id]?.callUUID = nil m.callInvitations[callInvitation.contact.id]?.callkitUUID = nil
} }
logger.error("reportNewIncomingCall error: \(error.localizedDescription)") logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
} else { } else {
+10 -18
View File
@@ -82,18 +82,10 @@ struct SimpleXApp: App {
if appState != .stopped { if appState != .stopped {
startChatAndActivate { startChatAndActivate {
if chatModel.chatRunning == true { if appState.inactive && chatModel.chatRunning == true {
if let ntfResponse = chatModel.notificationResponse { updateChats()
chatModel.notificationResponse = nil
NtfManager.shared.processNotificationResponse(ntfResponse)
}
if appState.inactive {
Task {
await updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations() updateCallInvitations()
}
}
} }
} }
} }
@@ -138,16 +130,16 @@ struct SimpleXApp: App {
} }
} }
private func updateChats() async { private func updateChats() {
do { do {
let chats = try await apiGetChatsAsync() let chats = try apiGetChats()
await MainActor.run { chatModel.updateChats(chats) } chatModel.updateChats(with: chats)
if let id = chatModel.chatId, if let id = chatModel.chatId,
let chat = chatModel.getChat(id) { let chat = chatModel.getChat(id) {
Task { await loadChat(chat: chat, clearItems: false) } loadChat(chat: chat)
} }
if let ncr = chatModel.ntfContactRequest { if let ncr = chatModel.ntfContactRequest {
await MainActor.run { chatModel.ntfContactRequest = nil } 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) }
} }
@@ -157,9 +149,9 @@ struct SimpleXApp: App {
} }
} }
private func updateCallInvitations() async { private func updateCallInvitations() {
do { do {
try await refreshCallInvitations() try 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))")
} }
+4 -4
View File
@@ -91,8 +91,8 @@ var systemInDarkThemeCurrently: Bool {
return UITraitCollection.current.userInterfaceStyle == .dark return UITraitCollection.current.userInterfaceStyle == .dark
} }
func reactOnDarkThemeChanges(_ inDarkNow: Bool) { func reactOnDarkThemeChanges() {
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow { if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == systemInDarkThemeCurrently {
// Change active colors from light to dark and back based on system theme // Change active colors from light to dark and back based on system theme
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
} }
@@ -102,7 +102,7 @@ extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper { public func importFromString() -> ThemeWallpaper {
if preset == nil, let image { if preset == nil, let image {
// Need to save image from string and to save its path // Need to save image from string and to save its path
if let parsed = imageFromBase64(image), if let parsed = UIImage(base64Encoded: image),
let filename = saveWallpaperFile(image: parsed) { let filename = saveWallpaperFile(image: parsed) {
var copy = self var copy = self
copy.image = nil copy.image = nil
@@ -122,7 +122,7 @@ extension ThemeWallpaper {
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil } 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 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 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 } let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
return ThemeWallpaper ( return ThemeWallpaper (
preset: preset, preset: preset,
scale: scale, scale: scale,
+2 -2
View File
@@ -53,7 +53,7 @@ class ThemeManager {
return perUserTheme return perUserTheme
} }
let defaultTheme = defaultActiveTheme(appSettingsTheme) 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)) return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper)
} }
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme { static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
@@ -197,7 +197,7 @@ class ThemeManager {
var themeIds = currentThemeIdsDefault.get() var themeIds = currentThemeIdsDefault.get()
themeIds[nonSystemThemeName] = prevValue.themeId themeIds[nonSystemThemeName] = prevValue.themeId
currentThemeIdsDefault.set(themeIds) currentThemeIdsDefault.set(themeIds)
applyTheme(currentThemeDefault.get()) applyTheme(nonSystemThemeName)
} }
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool { static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {
+105 -157
View File
@@ -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,39 +27,24 @@ 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.hasVideo { if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
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.hasVideo) { if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
ActiveCallOverlay(call: call, client: client) ActiveCallOverlay(call: call, client: client)
} }
} }
@@ -69,9 +54,6 @@ 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()
@@ -102,7 +84,7 @@ struct ActiveCallView: View {
private func createWebRTCClient() { private func createWebRTCClient() {
if client == nil && canConnectCall { if client == nil && canConnectCall {
client = WebRTCClient({ msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
Task { Task {
await m.callCommand.setClient(client) await m.callCommand.setClient(client)
} }
@@ -117,7 +99,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.initialCallType, capabilities: capabilities) let callType = CallType(media: call.localMedia, capabilities: capabilities)
Task { Task {
do { do {
try await apiSendCallInvitation(call.contact, callType) try await apiSendCallInvitation(call.contact, callType)
@@ -128,7 +110,7 @@ struct ActiveCallView: View {
call.callState = .invitationSent call.callState = .invitationSent
call.localCapabilities = capabilities call.localCapabilities = capabilities
} }
if call.hasVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() { if call.supportsVideo && !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()
@@ -138,7 +120,7 @@ struct ActiveCallView: View {
Task { Task {
do { do {
try await apiSendCallOffer(call.contact, offer, iceCandidates, try await apiSendCallOffer(call.contact, offer, iceCandidates,
media: call.initialCallType, capabilities: capabilities) media: call.localMedia, capabilities: capabilities)
} catch { } catch {
logger.error("apiSendCallOffer \(responseError(error))") logger.error("apiSendCallOffer \(responseError(error))")
} }
@@ -182,9 +164,6 @@ 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
} }
@@ -203,18 +182,10 @@ 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.callUUID { if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: uuid) CallController.shared.endCall(callUUID: uuid)
} }
case .ok: case .ok:
@@ -243,38 +214,16 @@ struct ActiveCallView: View {
ChatReceiver.shared.messagesChannel = nil ChatReceiver.shared.messagesChannel = nil
return return
} }
if case let .chatItemsStatusesUpdated(_, chatItems) = msg, if case let .chatItemStatusUpdated(_, msg) = msg,
chatItems.contains(where: { ci in msg.chatInfo.id == call.contact.id,
ci.chatInfo.id == call.contact.id && case .sndCall = msg.chatItem.content,
ci.chatItem.content.isSndCall && case .sndRcvd = msg.chatItem.meta.itemStatus {
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
@@ -290,16 +239,44 @@ struct ActiveCallOverlay: View {
var body: some View { var body: some View {
VStack { VStack {
switch call.hasVideo { switch call.localMedia {
case true: case .video:
videoCallInfoView(call) videoCallInfoView(call)
.foregroundColor(.white) .foregroundColor(.white)
.opacity(0.8) .opacity(0.8)
.padding(.horizontal) .padding()
// Fixed vertical padding required for preserving position of buttons row when changing audio-to-video and back in landscape orientation.
// Otherwise, bigger padding is added by SwiftUI when switching call types Spacer()
.padding(.vertical, 10)
case false: HStack {
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
@@ -314,32 +291,35 @@ struct ActiveCallOverlay: View {
} }
.foregroundColor(.white) .foregroundColor(.white)
.opacity(0.8) .opacity(0.8)
.padding(.horizontal) .padding()
.padding(.vertical, 10)
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
} }
}
Spacer() Spacer()
HStack { ZStack(alignment: .bottom) {
toggleMicButton() toggleAudioButton()
Spacer() .frame(maxWidth: .infinity, alignment: .leading)
audioDeviceButton()
Spacer()
endCallButton() endCallButton()
Spacer() // Check if the only input is microphone. And in this case show toggle button,
if call.localMediaSources.camera { // If there are more inputs, it probably means something like bluetooth headphones are available
flipCameraButton() // 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 { } else {
Color.clear.frame(width: 60, height: 60) Color.clear.frame(width: 50, height: 40)
} }
Spacer()
toggleCameraButton()
} }
.padding(.horizontal, 20) .padding(.bottom, 60)
.padding(.bottom, 16) .padding(.horizontal, 48)
.frame(maxWidth: 440, alignment: .center) }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.onAppear { .onAppear {
@@ -401,122 +381,90 @@ 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", .red, padding: 10) { return callButton("phone.down.fill", width: 60, height: 60) {
if let uuid = call.callUUID { if let uuid = call.callkitUUID {
cc.endCall(callUUID: uuid) cc.endCall(callUUID: uuid)
} else { } else {
cc.endCall(call: call) {} cc.endCall(call: call) {}
} }
} }
.foregroundColor(.red)
} }
private func toggleMicButton() -> some View { private func toggleAudioButton() -> some View {
controlButton(call, call.localMediaSources.mic ? "mic.fill" : "mic.slash", padding: 14) { controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") {
Task { Task {
if await WebRTCClient.isAuthorized(for: .audio) { client.setAudioEnabled(!call.audioEnabled)
client.setAudioEnabled(!call.localMediaSources.mic) DispatchQueue.main.async {
} else { WebRTCClient.showUnauthorizedAlert(for: .audio) } call.audioEnabled = !call.audioEnabled
} }
} }
} }
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.peerMediaSources.mic ? "speaker.slash" : call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill", padding: !call.peerMediaSources.mic ? 16 : call.speakerEnabled ? 15 : 17) { controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
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)
DispatchQueue.main.async {
call.speakerEnabled = !speakerEnabled call.speakerEnabled = !speakerEnabled
} }
}
}
.onAppear { .onAppear {
deviceManager.call = call deviceManager.call = call
//call.speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker //call.speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
} }
} }
private func toggleCameraButton() -> some View { private func toggleVideoButton() -> some View {
controlButton(call, call.localMediaSources.camera ? "video.fill" : "video.slash", padding: call.localMediaSources.camera ? 16 : 14) { controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") {
Task { Task {
if await WebRTCClient.isAuthorized(for: .video) { client.setVideoEnabled(!call.videoEnabled)
client.setCameraEnabled(!call.localMediaSources.camera) DispatchQueue.main.async {
} else { WebRTCClient.showUnauthorizedAlert(for: .video) } call.videoEnabled = !call.videoEnabled
}
} }
} }
.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", padding: 12) { controlButton(call, "arrow.triangle.2.circlepath") {
Task { Task {
if await WebRTCClient.isAuthorized(for: .video) {
client.flipCamera() client.flipCamera()
} }
} }
} }
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View {
if call.hasMedia {
callButton(imageName, width: 50, height: 38, perform)
.foregroundColor(.white)
.opacity(0.85)
} else {
Color.clear.frame(width: 50, height: 38)
}
} }
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View { private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> some View {
callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform)
}
@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()
.padding(padding) .frame(maxWidth: width, maxHeight: height)
.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, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .video), client: WebRTCClient({ _ in }, Binding.constant(nil))) ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
.background(.black) .background(.black)
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .audio), client: WebRTCClient({ _ in }, Binding.constant(nil))) ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), client: WebRTCClient(Binding.constant(nil), { _ in }, Binding.constant(nil)))
.background(.black) .background(.black)
} }
} }
+43 -93
View File
@@ -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.uuidString.lowercased()) { if callManager.startOutgoingCall(callUUID: action.callUUID) {
action.fulfill() action.fulfill()
provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil) provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
} else { } else {
@@ -61,39 +61,21 @@ 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")
Task { if callManager.answerIncomingCall(callUUID: action.callUUID) {
let chatIsReady = await waitUntilChatStarted(timeoutMs: 30_000, stepMs: 500)
logger.debug("CallController chat started \(chatIsReady) \(ChatModel.shared.chatInitialized) \(ChatModel.shared.chatRunning == true) \(String(describing: AppChatState.shared.value))")
if !chatIsReady {
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. // WebRTC call should be in connected state to fulfill.
// Otherwise no audio and mic working on lockscreen // Otherwise no audio and mic working on lockscreen
fulfillOnConnect = action fulfillOnConnect = action
} else { } else {
logger.debug("CallController.provider will fail the call")
action.fail() action.fail()
} }
} }
}
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) { func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
logger.debug("CallController.provider CXEndCallAction") logger.debug("CallController.provider CXEndCallAction")
// 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.uuidString.lowercased()) { ok in callManager.endCall(callUUID: action.callUUID) { ok in
if ok { if ok {
action.fulfill() action.fulfill()
} else { } else {
@@ -104,7 +86,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
} }
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) { if callManager.enableMedia(media: .audio, enable: !action.isMuted, callUUID: action.callUUID) {
action.fulfill() action.fulfill()
} else { } else {
action.fail() action.fail()
@@ -121,8 +103,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 hasVideo = ChatModel.shared.activeCall?.hasVideo == true let supportsVideo = ChatModel.shared.activeCall?.supportsVideo == true
if hasVideo { if supportsVideo {
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])
@@ -133,7 +115,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 hasVideo { } else if supportsVideo {
await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) } await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
} }
} }
@@ -174,19 +156,6 @@ 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)")
@@ -202,19 +171,32 @@ 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 displayName = payload.dictionaryPayload["displayName"] as? String, let invitation = m.callInvitations[contactId] {
let callUUID = payload.dictionaryPayload["callUUID"] as? String, let update = self.cxCallUpdate(invitation: invitation)
let uuid = UUID(uuidString: callUUID), if let uuid = invitation.callkitUUID {
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)
@@ -223,31 +205,11 @@ 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,
@@ -277,8 +239,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.callUUID))") logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let uuid = invitation.callkitUUID {
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)
@@ -299,14 +261,6 @@ 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() {
@@ -318,14 +272,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 callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let uuid = call.callkitUUID {
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 callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let uuid = invitation.callkitUUID {
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
@@ -334,17 +288,14 @@ 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 callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let uuid = call.callkitUUID {
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 callUUID = callManager.newOutgoingCall(contact, media) let uuid = 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)
@@ -356,17 +307,19 @@ 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: callUUID) { } else if callManager.startOutgoingCall(callUUID: uuid) {
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.callUUID, let uuid = UUID(uuidString: callUUID) { if CallController.useCallKit(), let callUUID = invitation.callkitUUID {
requestTransaction(with: CXAnswerCallAction(call: uuid)) requestTransaction(with: CXAnswerCallAction(call: callUUID))
} else { } else {
callManager.answerIncomingCall(invitation: invitation) callManager.answerIncomingCall(invitation: invitation)
} }
@@ -375,13 +328,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
} }
} }
func endCall(callUUID: String) { func endCall(callUUID: UUID) {
let uuid = UUID(uuidString: callUUID) logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)")
logger.debug("CallController: ending the call with UUID \(callUUID)")
if CallController.useCallKit() { if CallController.useCallKit() {
if let uuid { requestTransaction(with: CXEndCallAction(call: callUUID))
requestTransaction(with: CXEndCallAction(call: uuid))
}
} else { } else {
callManager.endCall(callUUID: callUUID) { ok in callManager.endCall(callUUID: callUUID) { ok in
if ok { if ok {
+16 -16
View File
@@ -10,25 +10,25 @@ import Foundation
import SimpleXChat import SimpleXChat
class CallManager { class CallManager {
func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> String { func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID {
let uuid = UUID().uuidString.lowercased() let uuid = UUID()
let call = Call(direction: .outgoing, contact: contact, callUUID: uuid, callState: .waitCapabilities, initialCallType: media) let call = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media)
call.speakerEnabled = media == .video call.speakerEnabled = media == .video
ChatModel.shared.activeCall = call ChatModel.shared.activeCall = call
return uuid return uuid
} }
func startOutgoingCall(callUUID: String) -> Bool { func startOutgoingCall(callUUID: UUID) -> Bool {
let m = ChatModel.shared let m = ChatModel.shared
if let call = m.activeCall, call.callUUID == callUUID { if let call = m.activeCall, call.callkitUUID == callUUID {
m.showCallView = true m.showCallView = true
Task { await m.callCommand.processCommand(.capabilities(media: call.initialCallType)) } Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
return true return true
} }
return false return false
} }
func answerIncomingCall(callUUID: String) -> Bool { func answerIncomingCall(callUUID: UUID) -> 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,
callUUID: invitation.callUUID, callkitUUID: invitation.callkitUUID,
callState: .invitationAccepted, callState: .invitationAccepted,
initialCallType: invitation.callType.media, localMedia: 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(source: CallMediaSource, enable: Bool, callUUID: String) -> Bool { func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID { if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
let m = ChatModel.shared let m = ChatModel.shared
Task { await m.callCommand.processCommand(.media(source: source, enable: enable)) } Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
return true return true
} }
return false return false
} }
func endCall(callUUID: String, completed: @escaping (Bool) -> Void) { func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) {
if let call = ChatModel.shared.activeCall, call.callUUID == callUUID { if let call = ChatModel.shared.activeCall, call.callkitUUID == 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: String) -> RcvCallInvitation? { private func getCallInvitation(_ callUUID: UUID) -> RcvCallInvitation? {
if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callUUID == callUUID }) { if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) {
return invitation return invitation
} }
return nil return nil
@@ -10,49 +10,40 @@ import AVKit
struct CallViewRemote: UIViewRepresentable { struct CallViewRemote: UIViewRepresentable {
var client: WebRTCClient var client: WebRTCClient
@ObservedObject var call: Call var activeCall: Binding<WebRTCClient.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()
let remoteCameraRenderer = RTCMTLVideoView(frame: view.frame) if let call = activeCall.wrappedValue {
remoteCameraRenderer.videoContentMode = contentMode let remoteRenderer = RTCMTLVideoView(frame: view.frame)
remoteCameraRenderer.tag = 0 remoteRenderer.videoContentMode = .scaleAspectFill
client.addRemoteRenderer(call, remoteRenderer)
let screenVideo = call.peerMediaSources.screenVideo addSubviewAndResize(remoteRenderer, into: view)
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() { if AVPictureInPictureController.isPictureInPictureSupported() {
makeViewWithRTCRenderer(remoteCameraRenderer, remoteScreenRenderer, view, context) makeViewWithRTCRenderer(call, remoteRenderer, view, context)
}
} }
return view return view
} }
func makeViewWithRTCRenderer(_ remoteCameraRenderer: RTCMTLVideoView, _ remoteScreenRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) { func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
let pipRemoteCameraRenderer = RTCMTLVideoView(frame: view.frame) let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
pipRemoteCameraRenderer.videoContentMode = .scaleAspectFill pipRemoteRenderer.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
@@ -64,9 +55,7 @@ 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.addRemoteCameraRenderer(pipRemoteCameraRenderer) client.addRemoteRenderer(call, pipRemoteRenderer)
client.addRemoteScreenRenderer(pipRemoteScreenRenderer)
context.coordinator.relayout()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
activeCallViewIsCollapsed = true activeCallViewIsCollapsed = true
} }
@@ -78,29 +67,13 @@ struct CallViewRemote: UIViewRepresentable {
} }
context.coordinator.didShowHide = { show in context.coordinator.didShowHide = { show in
if show { if show {
remoteCameraRenderer.isHidden = true remoteRenderer.isHidden = true
remoteScreenRenderer.isHidden = true
} else { } else {
client.removeRemoteCameraRenderer(pipRemoteCameraRenderer) client.removeRemoteRenderer(call, pipRemoteRenderer)
client.removeRemoteScreenRenderer(pipRemoteScreenRenderer) remoteRenderer.isHidden = false
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 */ {
@@ -115,49 +88,23 @@ struct CallViewRemote: UIViewRepresentable {
} }
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator(client) Coordinator()
} }
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)
@@ -180,20 +127,11 @@ 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)
}
} }
} }
@@ -210,109 +148,51 @@ 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, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) { init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, 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(_ fullscreen: UIView, _ end: UIView?, pip: Bool = false, into containerView: UIView) { private func addSubviewAndResize(_ view: UIView, into containerView: UIView) {
if containerView.subviews.firstIndex(of: fullscreen) == 0 && ((end == nil && containerView.subviews.count == 1) || (end != nil && containerView.subviews.firstIndex(of: end!) == 1)) { containerView.addSubview(view)
// Nothing to do, elements on their places view.translatesAutoresizingMaskIntoConstraints = false
return containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|",
}
containerView.removeConstraints(containerView.constraints)
containerView.subviews.forEach { sub in sub.removeFromSuperview()}
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: ["fullscreen": fullscreen] views: ["view": view]))
)
let constraintFullscreenH = NSLayoutConstraint.constraints( containerView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|",
withVisualFormat: "H:|[fullscreen]|",
options: [], options: [],
metrics: nil, metrics: nil,
views: ["fullscreen": fullscreen] views: ["view": view]))
)
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()
} }
@@ -38,7 +38,6 @@ 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) {
+19 -41
View File
@@ -18,38 +18,37 @@ class Call: ObservableObject, Equatable {
var direction: CallDirection var direction: CallDirection
var contact: Contact var contact: Contact
var callUUID: String? var callkitUUID: UUID?
var initialCallType: CallMediaType var localMedia: CallMediaType
@Published var localMediaSources: CallMediaSources
@Published var callState: CallState @Published var callState: CallState
@Published var localCapabilities: CallCapabilities? @Published var localCapabilities: CallCapabilities?
@Published var peerMediaSources: CallMediaSources = CallMediaSources() @Published var peerMedia: CallMediaType?
@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,
callUUID: String?, callkitUUID: UUID?,
callState: CallState, callState: CallState,
initialCallType: CallMediaType, localMedia: CallMediaType,
sharedKey: String? = nil sharedKey: String? = nil
) { ) {
self.direction = direction self.direction = direction
self.contact = contact self.contact = contact
self.callUUID = callUUID self.callkitUUID = callkitUUID
self.callState = callState self.callState = callState
self.initialCallType = initialCallType self.localMedia = localMedia
self.sharedKey = sharedKey self.sharedKey = sharedKey
self.localMediaSources = CallMediaSources( self.videoEnabled = localMedia == .video
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 } }
private var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } } var localEncrypted: Bool { get { localCapabilities?.encryption ?? false } }
var encryptionStatus: LocalizedStringKey { var encryptionStatus: LocalizedStringKey {
get { get {
switch callState { switch callState {
@@ -60,7 +59,8 @@ class Call: ObservableObject, Equatable {
} }
} }
} }
var hasVideo: Bool { get { localMediaSources.hasVideo || peerMediaSources.hasVideo } } var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
} }
enum CallDirection { enum CallDirection {
@@ -105,28 +105,18 @@ 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(source: CallMediaSource, enable: Bool) case media(media: CallMediaType, 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
@@ -177,9 +167,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(source, enable): case let .media(media, enable):
try container.encode("media", forKey: .type) try container.encode("media", forKey: .type)
try container.encode(source, forKey: .media) try container.encode(media, 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)
@@ -215,9 +205,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 source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source) let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
let enable = try container.decode(Bool.self, forKey: CodingKeys.enable) let enable = try container.decode(Bool.self, forKey: CodingKeys.enable)
self = .media(source: source, enable: enable) self = .media(media: media, enable: enable)
case "end": case "end":
self = .end self = .end
default: default:
@@ -234,7 +224,6 @@ 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)
@@ -249,8 +238,6 @@ 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 {
@@ -262,7 +249,6 @@ 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"
@@ -297,10 +283,6 @@ 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":
@@ -342,10 +324,6 @@ 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:
@@ -398,7 +376,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 != nil default: client.activeCall.wrappedValue != nil
} }
} }
} }
+103 -423
View File
@@ -23,24 +23,15 @@ 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 localAudioTrack: RTCAudioTrack? var localVideoSource: RTCVideoSource?
var localVideoTrack: RTCVideoTrack? var localStream: RTCVideoTrack?
var remoteAudioTrack: RTCAudioTrack? var remoteStream: RTCVideoTrack?
var remoteVideoTrack: RTCVideoTrack? var device: AVCaptureDevice.Position = .front
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 {
@@ -60,20 +51,17 @@ 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: Call? var activeCall: Binding<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(_ sendCallResponse: @escaping (WVAPIMessage) async -> Void, _ localRendererAspectRatio: Binding<CGFloat?>) { required init(_ activeCall: Binding<Call?>, _ 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()
@@ -90,45 +78,39 @@ 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
let device = notConnectedCall?.device ?? .front createAudioSender(connection)
var localStream: RTCVideoTrack? = nil
var remoteStream: RTCVideoTrack? = nil
var localCamera: RTCVideoCapturer? = nil var localCamera: RTCVideoCapturer? = nil
var localAudioTrack: RTCAudioTrack? = nil var localVideoSource: RTCVideoSource? = nil
var localVideoTrack: RTCVideoTrack? = nil if mediaType == .video {
if let localCameraAndTrack = notConnectedCall?.localCameraAndTrack { (localStream, remoteStream, localCamera, localVideoSource) = createVideoSender(connection)
(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,
localAudioTrack: localAudioTrack, localVideoSource: localVideoSource,
localVideoTrack: localVideoTrack, localStream: localStream,
device: device, remoteStream: remoteStream,
aesKey: aesKey, aesKey: aesKey,
frameEncryptor: frameEncryptor, frameEncryptor: frameEncryptor,
frameDecryptor: frameDecryptor, frameDecryptor: frameDecryptor
peerHasOldVersion: false
) )
} }
@@ -169,24 +151,18 @@ 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?.connection let pc = activeCall.wrappedValue?.connection
switch command { switch command {
case let .capabilities(media): // outgoing case .capabilities:
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): // incoming case let .start(media: media, aesKey, iceServers, relay):
logger.debug("starting incoming call - create webrtc session") logger.debug("starting incoming call - create webrtc session")
if activeCall != nil { endCall() } if activeCall.wrappedValue != 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 = call activeCall.wrappedValue = 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())),
@@ -196,24 +172,18 @@ 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): // outgoing case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
if activeCall != nil { if activeCall.wrappedValue != 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 = call activeCall.wrappedValue = 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)
@@ -230,7 +200,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
} }
} }
case let .answer(answer, iceCandidates): // incoming case let .answer(answer, iceCandidates):
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 {
@@ -242,9 +212,6 @@ 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(&currentDirection)
await adaptToOldVersion(currentDirection == .sendOnly)
addIceCandidates(pc, remoteIceCandidates) addIceCandidates(pc, remoteIceCandidates)
resp = .ok resp = .ok
} else { } else {
@@ -259,11 +226,13 @@ 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(source, enable): case let .media(media, enable):
if activeCall == nil { if activeCall.wrappedValue == 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 {
await enableMedia(source, enable) enableMedia(media, enable)
resp = .ok resp = .ok
} }
case .end: case .end:
@@ -278,7 +247,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?.iceCandidates.getAndClear() ?? [] let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)") logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
return candidates return candidates
} }
@@ -286,7 +255,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?.iceCandidates.getAndClear() ?? [] let candidates = await self.activeCall.wrappedValue?.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)
@@ -303,202 +272,25 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
) )
} }
func setupMuteUnmuteListener(_ transceiver: RTCRtpTransceiver, _ track: RTCMediaStreamTrack) { func enableMedia(_ media: CallMediaType, _ enable: Bool) {
// logger.log("Setting up mute/unmute listener in the call without encryption for mid = \(transceiver.mid)") logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
Task { media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
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)
}
}
} }
@MainActor func addLocalRenderer(_ activeCall: Call, _ renderer: RTCEAGLVideoView) {
func onMediaMuteUnmute(_ transceiverMid: String?, _ mute: Bool) { activeCall.localStream?.add(renderer)
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?.aesKey?.cString(using: .utf8), if var key: [CChar] = activeCall.wrappedValue?.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
@@ -512,7 +304,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?.aesKey?.cString(using: .utf8), if var key: [CChar] = activeCall.wrappedValue?.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
@@ -535,42 +327,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
} }
} }
func addRemoteCameraRenderer(_ renderer: RTCVideoRenderer) { func addRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
if activeCall?.remoteVideoTrack != nil { activeCall.remoteStream?.add(renderer)
activeCall?.remoteVideoTrack?.add(renderer)
} else {
cameraRenderers.append(renderer)
}
} }
func removeRemoteCameraRenderer(_ renderer: RTCVideoRenderer) { func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
if activeCall?.remoteVideoTrack != nil { activeCall.remoteStream?.remove(renderer)
activeCall?.remoteVideoTrack?.remove(renderer)
} else {
cameraRenderers.removeAll(where: { $0.isEqual(renderer) })
}
} }
func addRemoteScreenRenderer(_ renderer: RTCVideoRenderer) { func startCaptureLocalVideo(_ activeCall: Call) {
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 ?? notConnectedCall?.localCameraAndTrack?.0) as? RTCFileVideoCapturer let capturer = activeCall.localCamera as? RTCFileVideoCapturer
else { else {
logger.error("Unable to work with a file capturer") logger.error("Unable to work with a file capturer")
return return
@@ -580,10 +348,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 = capturer as? RTCCameraVideoCapturer, let capturer = activeCall.localCamera as? RTCCameraVideoCapturer,
let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == device }) let camera = (RTCCameraVideoCapturer.captureDevices().first { $0.position == activeCall.device })
else { else {
logger.error("Unable to find a camera or local track") logger.error("Unable to find a camera")
return return
} }
@@ -609,6 +377,19 @@ 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)
@@ -616,7 +397,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
return audioTrack return audioTrack
} }
private func createVideoTrackAndStartCapture(_ device: AVCaptureDevice.Position) -> (RTCVideoCapturer, RTCVideoTrack) { private func createVideoTrack() -> (RTCVideoTrack, RTCVideoCapturer, RTCVideoSource) {
let localVideoSource = WebRTCClient.factory.videoSource() let localVideoSource = WebRTCClient.factory.videoSource()
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
@@ -626,30 +407,19 @@ 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")
startCaptureLocalVideo(device, localCamera) return (localVideoTrack, localCamera, localVideoSource)
return (localCamera, localVideoTrack)
} }
func endCall() { func endCall() {
if #available(iOS 16.0, *) { guard let call = activeCall.wrappedValue else { return }
_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 {
@@ -658,7 +428,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?.connection.iceGatheringState != .complete } while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
} }
} }
@@ -719,40 +489,11 @@ 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 connectionStateString = newState.toString(), guard let call = activeCall.wrappedValue,
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()
@@ -773,14 +514,18 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
switch newState { switch newState {
case .checking: case .checking:
if let frameDecryptor = activeCall?.frameDecryptor { if let frameDecryptor = activeCall.wrappedValue?.frameDecryptor {
connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) } connection.receivers.forEach { $0.setRtcFrameDecryptor(frameDecryptor) }
} }
let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true let enableSpeaker: Bool
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: () default: do {}
} }
} }
} }
@@ -792,7 +537,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?.iceCandidates.append(candidate.toCandidate(nil, nil)) await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
} }
} }
@@ -847,42 +592,11 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
} }
extension WebRTCClient { extension WebRTCClient {
static func isAuthorized(for type: AVMediaType) async -> Bool { func setAudioEnabled(_ enabled: Bool) {
let status = AVCaptureDevice.authorizationStatus(for: type) setTrackEnabled(RTCAudioTrack.self, enabled)
var isAuthorized = status == .authorized
if status == .notDetermined {
isAuthorized = await AVCaptureDevice.requestAccess(for: type)
}
return isAuthorized
} }
static func showUnauthorizedAlert(for type: AVMediaType) { func setSpeakerEnabledAndConfigureSession( _ enabled: Bool) {
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 }
@@ -895,7 +609,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 && !skipExternalDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() { if hasExternalAudioDevice, 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)
@@ -905,7 +619,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 && !skipExternalDevice { if hasExternalAudioDevice {
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)
@@ -936,59 +650,25 @@ extension WebRTCClient {
} }
} }
@MainActor func setVideoEnabled(_ enabled: Bool) {
func setAudioEnabled(_ enabled: Bool) { setTrackEnabled(RTCVideoTrack.self, enabled)
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() {
let device = activeCall?.device ?? notConnectedCall?.device switch activeCall.wrappedValue?.device {
if activeCall != nil { case .front: activeCall.wrappedValue?.device = .back
activeCall?.device = device == .front ? .back : .front case .back: activeCall.wrappedValue?.device = .front
} else { default: ()
notConnectedCall?.device = device == .front ? .back : .front
} }
startCaptureLocalVideo( if let call = activeCall.wrappedValue {
activeCall?.device ?? notConnectedCall?.device, startCaptureLocalVideo(call)
(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 }
} }
} }
+4 -12
View File
@@ -92,7 +92,6 @@ struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var networkModel = NetworkModel.shared
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@State var contact: Contact @State var contact: Contact
@State var localAlias: String @State var localAlias: String
@@ -503,14 +502,14 @@ struct ChatInfoView: View {
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
.font(.system(size: 14)) .font(.system(size: 14))
Spacer() Spacer()
Text(networkModel.contactNetworkStatus(contact).statusString) Text(chatModel.contactNetworkStatus(contact).statusString)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
serverImage() serverImage()
} }
} }
private func serverImage() -> some View { private func serverImage() -> some View {
let status = networkModel.contactNetworkStatus(contact) let status = chatModel.contactNetworkStatus(contact)
return Image(systemName: status.imageName) return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : theme.colors.secondary) .foregroundColor(status == .connected ? .green : theme.colors.secondary)
.font(.system(size: 12)) .font(.system(size: 12))
@@ -558,7 +557,7 @@ struct ChatInfoView: View {
private func networkStatusAlert() -> Alert { private func networkStatusAlert() -> Alert {
Alert( Alert(
title: Text("Network status"), title: Text("Network status"),
message: Text(networkModel.contactNetworkStatus(contact).statusExplanation) message: Text(chatModel.contactNetworkStatus(contact).statusExplanation)
) )
} }
@@ -671,14 +670,7 @@ private struct CallButton: View {
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) { InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
if canCall { if canCall {
if CallController.useCallKit() {
CallController.shared.startCall(contact, mediaType) CallController.shared.startCall(contact, mediaType)
} else {
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
dismissAllSheets(animated: true) {
CallController.shared.startCall(contact, mediaType)
}
}
} else if contact.nextSendGrpInv { } else if contact.nextSendGrpInv {
showAlert(SomeAlert( showAlert(SomeAlert(
alert: mkAlert( alert: mkAlert(
@@ -942,7 +934,7 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
) )
} }
func queueInfoText(_ info: (RcvMsgInfo?, ServerQueueInfo)) -> String { func queueInfoText(_ info: (RcvMsgInfo?, QueueInfo)) -> String {
let (rcvMsgInfo, qInfo) = info let (rcvMsgInfo, qInfo) = info
var msgInfo: String var msgInfo: String
if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" } if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" }
@@ -11,11 +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 im = ItemsModel.shared
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme @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
@@ -106,9 +106,6 @@ 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( CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary))
chat: Chat.sampleData,
chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
).environment(\.revealed, true)
} }
} }
@@ -285,18 +285,17 @@ struct CIFileView_Previews: PreviewProvider {
file: nil file: nil
) )
Group { Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentFile) ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample()) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), 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(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), 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: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), 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(fileSize: 1_000_000_000, 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: "Hello there", 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: 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: fileChatItemWtFile) ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
} }
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }
} }
@@ -12,7 +12,6 @@ import SimpleXChat
struct CIGroupInvitationView: View { struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @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
@@ -46,7 +45,7 @@ struct CIGroupInvitationView: View {
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary) .foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
.font(.callout) .font(.callout)
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
@@ -54,7 +53,7 @@ struct CIGroupInvitationView: View {
( (
groupInvitationText() groupInvitationText()
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
@@ -70,7 +69,7 @@ struct CIGroupInvitationView: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background(chatItemFrameColor(chatItem, theme))
.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
@@ -165,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)
} }
@@ -16,7 +16,7 @@ struct CILinkView: View {
var body: some View { var body: some View {
VStack(alignment: .center, spacing: 6) { VStack(alignment: .center, spacing: 6) {
if let uiImage = imageFromBase64(linkPreview.image) { if let uiImage = UIImage(base64Encoded: linkPreview.image) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
@@ -12,13 +12,11 @@ import SimpleXChat
struct CIMetaView: View { struct CIMetaView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem var chatItem: ChatItem
var metaColor: Color 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
@@ -26,143 +24,93 @@ struct CIMetaView: View {
if chatItem.isDeletedContent { if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor) chatItem.timestampText.font(.caption).foregroundColor(metaColor)
} else { } else {
ZStack { let meta = chatItem.meta
ciMetaText( let ttl = chat.chatInfo.timedMessagesTTL
chatItem.meta, let encrypted = chatItem.encryptedFile
chatTTL: chat.chatInfo.timedMessagesTTL, switch meta.itemStatus {
encrypted: chatItem.encryptedFile, case let .sndSent(sndProgress):
color: metaColor, switch sndProgress {
paleColor: paleMetaColor, case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
colorMode: invertedMaterial case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
? .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 MetaColorMode { enum SentCheckmark {
// Renders provided colours case sent
case normal case rcvd1
// Fully transparent meta - used for reserving space case rcvd2
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, // we use this function to reserve space without rendering meta color: Color = .clear,
paleColor: Color? = nil,
primaryColor: Color = .accentColor, primaryColor: Color = .accentColor,
colorMode: MetaColorMode = .normal, transparent: Bool = false,
onlyOverrides: Bool = false, // only render colors that differ from base sent: SentCheckmark? = nil,
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", resolved) r = r + statusIconText("pencil", color)
} }
if meta.disappearing { if meta.disappearing {
r = r + statusIconText("timer", resolved).font(.caption2) r = r + statusIconText("timer", color).font(.caption2)
let ttl = meta.itemTimed?.ttl let ttl = meta.itemTimed?.ttl
if ttl != chatTTL { if ttl != chatTTL {
r = r + colored(Text(shortTimeText(ttl)), resolved) r = r + Text(shortTimeText(ttl)).foregroundColor(color)
} }
space = Text(" ") r = r + Text(" ")
} }
if showViaProxy, meta.sentViaProxy == true { if showViaProxy, meta.sentViaProxy == true {
appendSpace() r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2)
} }
if showStatus { if showStatus {
appendSpace() if let (icon, statusColor) = meta.statusIcon(color, primaryColor) {
if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) { let t = Text(Image(systemName: icon)).font(.caption2)
let metaColor = if onlyOverrides && statusColor == color { let gap = Text(" ").kerning(-1.25)
Color.clear let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
} else { switch sent {
colorMode.resolve(statusColor) case nil: r = r + t1
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 + colored(Text(image), metaColor) r = r + Text(" ")
} else if !meta.disappearing { } else if !meta.disappearing {
r = r + colorMode.statusSpacer(meta.itemStatus.sent) r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
} }
space = Text(" ")
} }
if let enc = encrypted { if let enc = encrypted {
appendSpace() r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
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 {
colored(Text(Image(systemName: icon)), color) Text(Image(systemName: icon)).foregroundColor(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 {
@@ -15,7 +15,6 @@ struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @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
@@ -48,7 +47,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 apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId) let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
if let s = stats { if let s = stats {
m.updateGroupMemberConnectionStats(groupInfo, member, s) m.updateGroupMemberConnectionStats(groupInfo, member, s)
} }
@@ -69,7 +68,6 @@ struct CIRcvDecryptionError: View {
} }
@ViewBuilder private func viewBody() -> some View { @ViewBuilder private func viewBody() -> some View {
Group {
if case let .direct(contact) = chat.chatInfo, if case let .direct(contact) = chat.chatInfo,
let contactStats = contact.activeConn?.connectionStats { let contactStats = contact.activeConn?.connectionStats {
if contactStats.ratchetSyncAllowed { if contactStats.ratchetSyncAllowed {
@@ -102,8 +100,6 @@ struct CIRcvDecryptionError: View {
basicDecryptionErrorItem() basicDecryptionErrorItem()
} }
} }
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
}
private func basicDecryptionErrorItem() -> some View { private func basicDecryptionErrorItem() -> some View {
decryptionErrorItem { alert = .decryptionErrorAlert } decryptionErrorItem { alert = .decryptionErrorAlert }
@@ -126,7 +122,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout) .font(.callout)
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
) )
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -135,6 +131,7 @@ struct CIRcvDecryptionError: View {
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -145,7 +142,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
@@ -153,6 +150,7 @@ struct CIRcvDecryptionError: View {
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -292,20 +292,28 @@ struct CIVideoView: View {
.clipShape(Circle()) .clipShape(Circle())
} }
private var fileSizeString: String {
if let file = chatItem.file, !videoPlaying {
" " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
} else {
""
}
}
private func durationProgress() -> some View { private func durationProgress() -> some View {
Text((durationText(videoPlaying ? progress : duration)) + fileSizeString) HStack {
.invertedForegroundStyle() Text("\(durationText(videoPlaying ? progress : duration))")
.foregroundColor(.white)
.font(.caption) .font(.caption)
.padding(.vertical, 6) .padding(.vertical, 3)
.padding(.horizontal, 12) .padding(.horizontal, 6)
.background(Color.black.opacity(0.35))
.cornerRadius(10)
.padding([.top, .leading], 6)
if let file = chatItem.file, !videoPlaying {
Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
.foregroundColor(.white)
.font(.caption)
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color.black.opacity(0.35))
.cornerRadius(10)
.padding(.top, 6)
}
}
} }
private func imageView(_ img: UIImage) -> some View { private func imageView(_ img: UIImage) -> some View {
@@ -403,9 +411,9 @@ 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(smallView ? 0 : padding)
} }
@@ -420,8 +428,10 @@ struct CIVideoView: View {
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(style: StrokeStyle(lineWidth: 2)) .stroke(
.invertedForegroundStyle() Color(uiColor: .white),
style: StrokeStyle(lineWidth: 2)
)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11) .padding([.trailing, .top], smallView ? 0 : 11)
@@ -510,10 +510,10 @@ struct CIVoiceView_Previews: PreviewProvider {
duration: 30, duration: 30,
allowMenu: Binding.constant(true) allowMenu: Binding.constant(true)
) )
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
} }
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }
@@ -92,13 +92,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete) file: CIFile.getSample(fileStatus: .sndComplete)
) )
Group { Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage) ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there")) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), 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: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), 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: 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: voiceMessageWithQuote) ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
} }
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
} }
} }
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ import AVKit
struct FullScreenMediaView: View { struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var scrollModel: ReverseListScrollModel @EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
@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
@@ -69,7 +69,7 @@ struct CIMsgError: View {
} }
.padding(.leading, 12) .padding(.leading, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
.onTapGesture(perform: onTap) .onTapGesture(perform: onTap)
} }
@@ -12,9 +12,9 @@ import SimpleXChat
struct MarkedDeletedItemView: View { struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @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)
@@ -22,7 +22,7 @@ struct MarkedDeletedItemView: View {
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background(chatItemFrameColor(chatItem, theme))
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -79,10 +79,7 @@ struct MarkedDeletedItemView: View {
struct MarkedDeletedItemView_Previews: PreviewProvider { struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
MarkedDeletedItemView( MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
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,7 +26,6 @@ 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 @EnvironmentObject var theme: AppTheme
var text: String var text: String
var formattedText: [FormattedText]? = nil var formattedText: [FormattedText]? = nil
@@ -85,7 +84,7 @@ struct MsgContentView: View {
} }
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
} }
} }
@@ -14,11 +14,12 @@ struct ChatItemForwardingView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
var chatItems: [ChatItem] var ci: 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? @State private var alert: SomeAlert?
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats) private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
@@ -45,6 +46,8 @@ struct ChatItemForwardingView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if !chatsToForwardTo.isEmpty { if !chatsToForwardTo.isEmpty {
List { List {
searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary)
.padding(.leading, 2)
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) } let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
ForEach(chats) { chat in ForEach(chats) { chat in
@@ -52,7 +55,6 @@ struct ChatItemForwardingView: View {
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id)) .disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
} }
} }
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} else { } else {
ZStack { ZStack {
@@ -71,14 +73,11 @@ struct ChatItemForwardingView: View {
} }
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View { @ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = chatItems.map { ci in let prohibited = chat.prohibitedByPref(
chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text), hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false, isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false isVoice: ci.content.msgContent?.isVoice ?? false
) )
}.contains(true)
Button { Button {
if prohibited { if prohibited {
alert = SomeAlert( alert = SomeAlert(
@@ -94,11 +93,11 @@ struct ChatItemForwardingView: View {
composeState = ComposeState( composeState = ComposeState(
message: composeState.message, message: composeState.message,
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview, preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo) contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
) )
} else { } else {
composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo) composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id) chatModel.chatId = chat.id
} }
} }
} label: { } label: {
@@ -124,7 +123,7 @@ struct ChatItemForwardingView: View {
#Preview { #Preview {
ChatItemForwardingView( ChatItemForwardingView(
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")], ci: 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()) ).environmentObject(CurrentColors.toAppTheme())
@@ -351,7 +351,7 @@ struct ChatItemInfoView: View {
Button { Button {
Task { Task {
await MainActor.run { await MainActor.run {
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) chatModel.chatId = forwardedFromItem.chatInfo.id
dismiss() dismiss()
} }
} }
@@ -450,8 +450,20 @@ struct ChatItemInfoView: View {
.foregroundColor(theme.colors.secondary).opacity(0.67) .foregroundColor(theme.colors.secondary).opacity(0.67)
} }
let v = Group { let v = Group {
let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
image.foregroundColor(statusColor) switch status {
case .rcvd:
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)
}
} }
if let (title, text) = status.statusInfo { if let (title, text) = status.statusInfo {
+29 -44
View File
@@ -9,33 +9,12 @@
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 @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
@Binding var revealed: Bool
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
init( init(
@@ -43,25 +22,27 @@ struct ChatItemView: View {
chatItem: ChatItem, chatItem: ChatItem,
showMember: Bool = false, showMember: Bool = false,
maxWidth: CGFloat = .infinity, maxWidth: CGFloat = .infinity,
revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false) allowMenu: Binding<Bool> = .constant(false)
) { ) {
self.chat = chat self.chat = chat
self.chatItem = chatItem self.chatItem = chatItem
self.maxWidth = maxWidth self.maxWidth = maxWidth
_revealed = revealed
_allowMenu = allowMenu _allowMenu = allowMenu
} }
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) MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
} 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, 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, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else { } else {
framedItemView() framedItemView()
} }
@@ -79,7 +60,7 @@ struct ChatItemView: View {
default: nil default: nil
} }
} }
.flatMap { imageFromBase64($0) } .flatMap { UIImage(base64Encoded: $0) }
let adjustedMaxWidth = { let adjustedMaxWidth = {
if let preview, preview.size.width <= preview.size.height { if let preview, preview.size.width <= preview.size.height {
maxWidth * 0.75 maxWidth * 0.75
@@ -91,6 +72,7 @@ struct ChatItemView: View {
chat: chat, chat: chat,
chatItem: chatItem, chatItem: chatItem,
preview: preview, preview: preview,
revealed: $revealed,
maxWidth: maxWidth, maxWidth: maxWidth,
imgWidth: adjustedMaxWidth, imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth, videoWidth: adjustedMaxWidth,
@@ -102,9 +84,9 @@ struct ChatItemView: View {
struct ChatItemContentView<Content: View>: View { struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @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
@@ -136,7 +118,7 @@ struct ChatItemContentView<Content: View>: View {
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, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary) CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, 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(theme.colors.secondary)) 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(theme.colors.secondary)) 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)
@@ -183,7 +165,7 @@ struct ChatItemContentView<Content: View>: View {
} }
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor) CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
} }
private var mergedGroupEventText: Text? { private var mergedGroupEventText: Text? {
@@ -244,17 +226,16 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
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")) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too")) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂")) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .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.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), 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, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
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, "🙂", .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) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
} }
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70)) .previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
} }
@@ -272,7 +253,8 @@ 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,
@@ -282,7 +264,8 @@ 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,
@@ -292,7 +275,8 @@ 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,
@@ -302,7 +286,8 @@ 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,
@@ -312,10 +297,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)
} }
+130 -423
View File
@@ -22,7 +22,8 @@ struct ChatView: View {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State @ObservedObject var chat: Chat @State @ObservedObject var chat: Chat
@StateObject private var scrollModel = ReverseListScrollModel() @StateObject private var scrollModel = ReverseListScrollModel<ChatItem>()
@StateObject private var floatingButtonModel = FloatingButtonModel()
@State private var showChatInfoSheet: Bool = false @State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false @State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState() @State private var composeState = ComposeState()
@@ -42,7 +43,6 @@ struct ChatView: View {
@State private var showGroupLinkSheet: Bool = false @State private var showGroupLinkSheet: Bool = false
@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 forwardedChatItems: [ChatItem] = []
@State private var selectedChatItems: Set<Int64>? = nil @State private var selectedChatItems: Set<Int64>? = nil
@State private var showDeleteSelectedMessages: Bool = false @State private var showDeleteSelectedMessages: Bool = false
@State private var allowToDeleteSelectedMessagesForAll: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false
@@ -53,6 +53,7 @@ struct ChatView: View {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
viewBody viewBody
.scrollDismissesKeyboard(.immediately) .scrollDismissesKeyboard(.immediately)
.keyboardPadding()
.toolbarBackground(.hidden, for: .navigationBar) .toolbarBackground(.hidden, for: .navigationBar)
} else { } else {
viewBody viewBody
@@ -76,7 +77,7 @@ struct ChatView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
chatItemsList() chatItemsList()
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat) floatingButtons(counts: floatingButtonModel.unreadChatItemCounts)
} }
connectingText() connectingText()
if selectedChatItems == nil { if selectedChatItems == nil {
@@ -99,8 +100,7 @@ struct ChatView: View {
if case let .group(groupInfo) = chat.chatInfo { if case let .group(groupInfo) = chat.chatInfo {
showModerateSelectedMessagesAlert(groupInfo) showModerateSelectedMessagesAlert(groupInfo)
} }
}, }
forwardItems: forwardSelectedMessages
) )
} }
} }
@@ -130,47 +130,16 @@ struct ChatView: View {
} }
} }
} }
.appSheet(item: $selectedMember) { member in
Group {
if case let .group(groupInfo) = chat.chatInfo {
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
}
}
// it should be presented on top level in order to prevent a bug in SwiftUI on iOS 16 related to .focused() modifier in AddGroupMembersView's search field
.appSheet(isPresented: $showAddMembersSheet) {
Group {
if case let .group(groupInfo) = cInfo {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
}
}
.sheet(isPresented: Binding(
get: { !forwardedChatItems.isEmpty },
set: { isPresented in
if !isPresented {
forwardedChatItems = []
selectedChatItems = nil
}
}
)) {
if #available(iOS 16.0, *) {
ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
.presentationDetents([.fraction(0.8)])
} else {
ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
}
}
.onAppear { .onAppear {
selectedChatItems = nil loadChat(chat: chat)
initChatView() initChatView()
selectedChatItems = nil
} }
.onChange(of: chatModel.chatId) { cId in .onChange(of: chatModel.chatId) { cId in
showChatInfoSheet = false showChatInfoSheet = false
selectedChatItems = nil
scrollModel.scrollToBottom()
stopAudioPlayer() stopAudioPlayer()
if let cId { if let cId {
selectedChatItems = nil
if let c = chatModel.getChat(cId) { if let c = chatModel.getChat(cId) {
chat = c chat = c
} }
@@ -183,10 +152,8 @@ struct ChatView: View {
.onChange(of: revealedChatItem) { _ in .onChange(of: revealedChatItem) { _ in
NotificationCenter.postReverseListNeedsLayout() NotificationCenter.postReverseListNeedsLayout()
} }
.onChange(of: im.isLoading) { isLoading in .onChange(of: im.reversedChatItems) { reversedChatItems in
if !isLoading, if reversedChatItems.count <= loadItemsPerPage && filtered(reversedChatItems).count < 10 {
im.reversedChatItems.count <= loadItemsPerPage,
filtered(im.reversedChatItems).count < 10 {
loadChatItems(chat.chatInfo) loadChatItems(chat.chatInfo)
} }
} }
@@ -254,7 +221,6 @@ struct ChatView: View {
} }
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
let isLoading = im.isLoading && im.showLoadingProgress
if selectedChatItems != nil { if selectedChatItems != nil {
Button { Button {
withAnimation { withAnimation {
@@ -277,7 +243,6 @@ struct ChatView: View {
} }
} }
Menu { Menu {
if !isLoading {
if callsPrefEnabled && chatModel.activeCall == nil { if callsPrefEnabled && chatModel.activeCall == nil {
Button { Button {
CallController.shared.startCall(contact, .video) CallController.shared.startCall(contact, .video)
@@ -289,11 +254,8 @@ struct ChatView: View {
searchButton() searchButton()
ToggleNtfsButton(chat: chat) ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active) .disabled(!contact.ready || !contact.active)
}
} label: { } label: {
Image(systemName: "ellipsis") Image(systemName: "ellipsis")
.tint(isLoading ? Color.clear : nil)
.overlay { if isLoading { ProgressView() } }
} }
} }
case let .group(groupInfo): case let .group(groupInfo):
@@ -312,17 +274,16 @@ struct ChatView: View {
} }
} else { } else {
addMembersButton() addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
} }
} }
Menu { Menu {
if !isLoading {
searchButton() searchButton()
ToggleNtfsButton(chat: chat) ToggleNtfsButton(chat: chat)
}
} label: { } label: {
Image(systemName: "ellipsis") Image(systemName: "ellipsis")
.tint(isLoading ? Color.clear : nil)
.overlay { if isLoading { ProgressView() } }
} }
} }
case .local: case .local:
@@ -362,7 +323,6 @@ struct ChatView: View {
await markChatUnread(chat, unreadChat: false) await markChatUnread(chat, unreadChat: false)
} }
} }
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
} }
private func searchToolbar() -> some View { private func searchToolbar() -> some View {
@@ -389,7 +349,9 @@ struct ChatView: View {
searchText = "" searchText = ""
searchMode = false searchMode = false
searchFocussed = false searchFocussed = false
Task { await loadChat(chat: chat) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
loadChat(chat: chat)
}
} }
} }
.padding(.horizontal) .padding(.horizontal)
@@ -434,23 +396,38 @@ struct ChatView: View {
composeState: $composeState, composeState: $composeState,
selectedMember: $selectedMember, selectedMember: $selectedMember,
revealedChatItem: $revealedChatItem, revealedChatItem: $revealedChatItem,
selectedChatItems: $selectedChatItems, selectedChatItems: $selectedChatItems
forwardedChatItems: $forwardedChatItems
) )
.onAppear {
floatingButtonModel.appeared(viewId: ci.viewId)
}
.onDisappear {
floatingButtonModel.disappeared(viewId: ci.viewId)
}
.id(ci.id) // Required to trigger `onAppear` on iOS15 .id(ci.id) // Required to trigger `onAppear` on iOS15
} loadPage: { } loadPage: {
loadChatItems(cInfo) loadChatItems(cInfo)
} }
.opacity(ItemsModel.shared.isLoading ? 0 : 1)
.padding(.vertical, -InvertedTableView.inset) .padding(.vertical, -InvertedTableView.inset)
.onTapGesture { hideKeyboard() } .onTapGesture { hideKeyboard() }
.onChange(of: searchText) { _ in .onChange(of: searchText) { _ in
Task { await loadChat(chat: chat, search: searchText) } loadChat(chat: chat, search: searchText)
}
.onChange(of: chatModel.chatId) { chatId in
if let chatId, let c = chatModel.getChat(chatId) {
chat = c
showChatInfoSheet = false
loadChat(chat: c)
scrollModel.scrollToBottom()
}
}
.onChange(of: im.reversedChatItems) { _ in
floatingButtonModel.chatItemsChanged()
} }
.onChange(of: im.itemAdded) { added in .onChange(of: im.itemAdded) { added in
if added { if added {
im.itemAdded = false im.itemAdded = false
if FloatingButtonModel.shared.isReallyNearBottom { if floatingButtonModel.unreadChatItemCounts.isReallyNearBottom {
scrollModel.scrollToBottom() scrollModel.scrollToBottom()
} }
} }
@@ -473,100 +450,57 @@ struct ChatView: View {
} }
class FloatingButtonModel: ObservableObject { class FloatingButtonModel: ObservableObject {
static let shared = FloatingButtonModel() private enum Event {
@Published var unreadBelow: Int = 0 case appeared(String)
@Published var isNearBottom: Bool = true case disappeared(String)
@Published var date: Date? case chatItemsChanged
@Published var isDateVisible: Bool = false
var totalUnread: Int = 0
var isReallyNearBottom: Bool = true
var hideDateWorkItem: DispatchWorkItem?
func updateOnListChange(_ listState: ListState) {
let im = ItemsModel.shared
let unreadBelow =
if let id = listState.bottomItemId,
let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
{
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
if chatItem.isRcvNew { unread += 1 }
}
} else {
0
}
let date: Date? =
if let topItemDate = listState.topItemDate {
Calendar.current.startOfDay(for: topItemDate)
} else {
nil
} }
// set the counters and date indicator @Published var unreadChatItemCounts: UnreadChatItemCounts
DispatchQueue.main.async { [weak self] in
guard let it = self else { return } private let events = PassthroughSubject<Event, Never>()
it.setDate(visibility: true) private var bag = Set<AnyCancellable>()
it.unreadBelow = unreadBelow
it.date = date init() {
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500 unreadChatItemCounts = UnreadChatItemCounts(
isNearBottom: true,
isReallyNearBottom: true,
unreadBelow: 0
)
events
.receive(on: DispatchQueue.global(qos: .background))
.scan(Set<String>()) { itemsInView, event in
var updated = itemsInView
switch event {
case let .appeared(viewId): updated.insert(viewId)
case let .disappeared(viewId): updated.remove(viewId)
case .chatItemsChanged: ()
}
return updated
}
.map { ChatModel.shared.unreadChatItemCounts(itemsInView: $0) }
.removeDuplicates()
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
.assign(to: \.unreadChatItemCounts, on: self)
.store(in: &bag)
} }
// set floating button indication mode func appeared(viewId: String) {
let nearBottom = listState.scrollOffset < 800 events.send(.appeared(viewId))
if nearBottom != self.isNearBottom { }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
self?.isNearBottom = nearBottom func disappeared(viewId: String) {
events.send(.disappeared(viewId))
}
func chatItemsChanged() {
events.send(.chatItemsChanged)
} }
} }
// hide Date indicator after 1 second of no scrolling private func floatingButtons(counts: UnreadChatItemCounts) -> some View {
hideDateWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let it = self else { return }
it.setDate(visibility: false)
it.hideDateWorkItem = nil
}
DispatchQueue.main.async { [weak self] in
self?.hideDateWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
}
}
func resetDate() {
date = nil
isDateVisible = false
}
private func setDate(visibility isVisible: Bool) {
if isVisible {
if !isNearBottom,
!isDateVisible,
let date, !Calendar.current.isDateInToday(date) {
withAnimation { self.isDateVisible = true }
}
} else if isDateVisible {
withAnimation { self.isDateVisible = false }
}
}
}
private struct FloatingButtons: View {
let theme: AppTheme
let scrollModel: ReverseListScrollModel
let chat: Chat
@ObservedObject var model = FloatingButtonModel.shared
var body: some View {
ZStack(alignment: .top) {
if let date = model.date {
DateSeparator(date: date)
.padding(.vertical, 4).padding(.horizontal, 8)
.background(.thinMaterial)
.clipShape(Capsule())
.opacity(model.isDateVisible ? 1 : 0)
}
VStack { VStack {
let unreadAbove = model.totalUnread - model.unreadBelow let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow
if unreadAbove > 0 { if unreadAbove > 0 {
circleButton { circleButton {
unreadCountText(unreadAbove) unreadCountText(unreadAbove)
@@ -587,16 +521,16 @@ struct ChatView: View {
} }
} }
Spacer() Spacer()
if model.unreadBelow > 0 { if counts.unreadBelow > 0 {
circleButton { circleButton {
unreadCountText(model.unreadBelow) unreadCountText(counts.unreadBelow)
.font(.callout) .font(.callout)
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
} }
.onTapGesture { .onTapGesture {
scrollModel.scrollToBottom() scrollModel.scrollToBottom()
} }
} else if !model.isNearBottom { } else if !counts.isNearBottom {
circleButton { circleButton {
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
@@ -605,9 +539,6 @@ struct ChatView: View {
} }
} }
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .trailing)
}
.onDisappear(perform: model.resetDate)
} }
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View { private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
@@ -618,26 +549,6 @@ struct ChatView: View {
content() content()
} }
} }
}
private struct DateSeparator: View {
let date: Date
var body: some View {
Text(String.localizedStringWithFormat(
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
date.formatted(.dateTime.weekday(.abbreviated)),
date.formatted(
Calendar.current.isDate(date, equalTo: .now, toGranularity: .year)
? .dateTime.day().month(.abbreviated)
: .dateTime.day().month(.abbreviated).year()
)
))
.font(.callout)
.fontWeight(.medium)
.foregroundStyle(.secondary)
}
}
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
Button { Button {
@@ -649,8 +560,8 @@ struct ChatView: View {
private func endCallButton(_ call: Call) -> some View { private func endCallButton(_ call: Call) -> some View {
Button { Button {
if CallController.useCallKit(), let callUUID = call.callUUID { if let uuid = call.callkitUUID {
CallController.shared.endCall(callUUID: callUUID) CallController.shared.endCall(callUUID: uuid)
} else { } else {
CallController.shared.endCall(call: call) {} CallController.shared.endCall(call: call) {}
} }
@@ -729,116 +640,6 @@ struct ChatView: View {
} }
} }
private func forwardSelectedMessages() {
Task {
do {
if let selectedChatItems {
let (validItems, confirmation) = try await apiPlanForwardChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemIds: Array(selectedChatItems)
)
if let confirmation {
if validItems.count > 0 {
showAlert(
String.localizedStringWithFormat(
NSLocalizedString("Forward %d message(s)?", comment: "alert title"),
validItems.count
),
message: forwardConfirmationText(confirmation) + "\n" +
NSLocalizedString("Forward messages without files?", comment: "alert message")
) {
switch confirmation {
case let .filesNotAccepted(fileIds):
[forwardAction(validItems), downloadAction(fileIds), cancelAlertAction]
default:
[forwardAction(validItems), cancelAlertAction]
}
}
} else {
showAlert(
NSLocalizedString("Nothing to forward!", comment: "alert title"),
message: forwardConfirmationText(confirmation)
) {
switch confirmation {
case let .filesNotAccepted(fileIds):
[downloadAction(fileIds), cancelAlertAction]
default:
[okAlertAction]
}
}
}
} else {
await openForwardingSheet(validItems)
}
}
} catch {
logger.error("Plan forward chat items failed: \(error.localizedDescription)")
}
}
func forwardConfirmationText(_ fc: ForwardConfirmation) -> String {
switch fc {
case let .filesNotAccepted(fileIds):
String.localizedStringWithFormat(
NSLocalizedString("%d file(s) were not downloaded.", comment: "forward confirmation reason"),
fileIds.count
)
case let .filesInProgress(filesCount):
String.localizedStringWithFormat(
NSLocalizedString("%d file(s) are still being downloaded.", comment: "forward confirmation reason"),
filesCount
)
case let .filesMissing(filesCount):
String.localizedStringWithFormat(
NSLocalizedString("%d file(s) were deleted.", comment: "forward confirmation reason"),
filesCount
)
case let .filesFailed(filesCount):
String.localizedStringWithFormat(
NSLocalizedString("%d file(s) failed to download.", comment: "forward confirmation reason"),
filesCount
)
}
}
func forwardAction(_ items: [Int64]) -> UIAlertAction {
UIAlertAction(
title: NSLocalizedString("Forward messages", comment: "alert action"),
style: .default,
handler: { _ in Task { await openForwardingSheet(items) } }
)
}
func downloadAction(_ fileIds: [Int64]) -> UIAlertAction {
UIAlertAction(
title: NSLocalizedString("Download files", comment: "alert action"),
style: .default,
handler: { _ in
Task {
if let user = ChatModel.shared.currentUser {
await receiveFiles(user: user, fileIds: fileIds)
}
}
}
)
}
func openForwardingSheet(_ items: [Int64]) async {
let im = ItemsModel.shared
var items = Set(items)
var fci = [ChatItem]()
for reversedChatItem in im.reversedChatItems {
if items.contains(reversedChatItem.id) {
items.remove(reversedChatItem.id)
fci.insert(reversedChatItem, at: 0)
}
if items.isEmpty { break }
}
await MainActor.run { forwardedChatItems = fci }
}
}
private func loadChatItems(_ cInfo: ChatInfo) { private func loadChatItems(_ cInfo: ChatInfo) {
Task { Task {
if loadingItems || firstPage { return } if loadingItems || firstPage { return }
@@ -887,7 +688,6 @@ struct ChatView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding @ObservedObject var chat: Chat @Binding @ObservedObject var chat: Chat
@ObservedObject var dummyModel: ChatItemDummyModel = .shared
let chatItem: ChatItem let chatItem: ChatItem
let maxWidth: CGFloat let maxWidth: CGFloat
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@@ -900,47 +700,26 @@ struct ChatView: View {
@State private var showDeleteMessages = false @State private var showDeleteMessages = false
@State private var showChatItemInfoSheet: Bool = false @State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo? @State private var chatItemInfo: ChatItemInfo?
@State private var msgWidth: CGFloat = 0 @State private var showForwardingSheet: Bool = false
@Binding var selectedChatItems: Set<Int64>? @Binding var selectedChatItems: Set<Int64>?
@Binding var forwardedChatItems: [ChatItem]
@State private var allowMenu: Bool = true @State private var allowMenu: Bool = true
@State private var markedRead = false
var revealed: Bool { chatItem == revealedChatItem } var revealed: Bool { chatItem == revealedChatItem }
typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?)
func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
let im = ItemsModel.shared
if let i, i > 0 && im.reversedChatItems.count >= i {
let nextItem = im.reversedChatItems[i - 1]
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
return (
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
largeGap: largeGap,
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
)
} else {
return (timestamp: true, largeGap: true, date: nil)
}
}
var body: some View { var body: some View {
let currIndex = m.getChatItemIndex(chatItem) let (currIndex, _) = m.getNextChatItem(chatItem)
let ciCategory = chatItem.mergeCategory let ciCategory = chatItem.mergeCategory
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden) let range = itemsRange(currIndex, prevHidden)
let timeSeparation = getItemSeparation(chatItem, at: currIndex)
let im = ItemsModel.shared let im = ItemsModel.shared
Group { Group {
if revealed, let range = range { if revealed, let range = range {
let items = Array(zip(Array(range), im.reversedChatItems[range])) let items = Array(zip(Array(range), im.reversedChatItems[range]))
VStack(spacing: 0) { ForEach(items, id: \.1.viewId) { (i, ci) in
ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1] let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
chatItemView(ci, nil, prev, getItemSeparation(ci, at: i)) chatItemView(ci, nil, prev)
.overlay { .overlay {
if let selected = selectedChatItems, ci.canBeDeletedForSelf { if let selected = selectedChatItems, ci.canBeDeletedForSelf {
Color.clear Color.clear
@@ -952,14 +731,8 @@ struct ChatView: View {
} }
} }
} }
}
} else { } else {
VStack(spacing: 0) { chatItemView(chatItem, range, prevItem)
chatItemView(chatItem, range, prevItem, timeSeparation)
if let date = timeSeparation.date {
DateSeparator(date: date).padding(8)
}
}
.overlay { .overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear Color.clear
@@ -973,16 +746,12 @@ struct ChatView: View {
} }
} }
.onAppear { .onAppear {
if markedRead {
return
} else {
markedRead = true
}
if let range { if let range {
let itemIds = unreadItemIds(range) if let items = unreadItems(range) {
if !itemIds.isEmpty {
waitToMarkRead { waitToMarkRead {
await apiMarkChatItemsRead(chat.chatInfo, itemIds) for ci in items {
await apiMarkChatItemRead(chat.chatInfo, ci)
}
} }
} }
} else if chatItem.isRcvNew { } else if chatItem.isRcvNew {
@@ -993,74 +762,28 @@ struct ChatView: View {
} }
} }
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] { private func unreadItems(_ range: ClosedRange<Int>) -> [ChatItem]? {
let im = ItemsModel.shared let im = ItemsModel.shared
return range.compactMap { i in let items = range.compactMap { i in
if i >= 0 && i < im.reversedChatItems.count { if i >= 0 && i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i] let ci = im.reversedChatItems[i]
return if ci.isRcvNew { ci.id } else { nil } return if ci.isRcvNew { ci } else { nil }
} else { } else {
return nil return nil
} }
} }
return if items.isEmpty { nil } else { items }
} }
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
Task { DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
_ = try? await Task.sleep(nanoseconds: 600_000000)
if m.chatId == chat.chatInfo.id { if m.chatId == chat.chatInfo.id {
await op() Task(operation: op)
} }
} }
} }
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?) -> some View {
@available(iOS 16.0, *)
struct MemberLayout: Layout {
let spacing: Double
let msgWidth: Double
private func sizes(subviews: Subviews, proposal: ProposedViewSize) -> (CGSize, CGSize) {
assert(subviews.count == 2, "member layout must contain exactly two subviews")
let roleSize = subviews[1].sizeThatFits(proposal)
let memberSize = subviews[0].sizeThatFits(
ProposedViewSize(
width: (proposal.width ?? msgWidth) - roleSize.width,
height: proposal.height
)
)
return (memberSize, roleSize)
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
return CGSize(
width: min(
proposal.width ?? msgWidth,
max(msgWidth, roleSize.width + spacing + memberSize.width)
),
height: max(memberSize.height, roleSize.height)
)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
subviews[0].place(
at: CGPoint(x: bounds.minX, y: bounds.midY - memberSize.height / 2),
proposal: ProposedViewSize(memberSize)
)
subviews[1].place(
at: CGPoint(
x: bounds.minX + max(memberSize.width + spacing, msgWidth - roleSize.width),
y: bounds.midY - roleSize.height / 2
),
proposal: ProposedViewSize(roleSize)
)
}
}
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?, _ itemSeparation: ItemSeparation) -> some View {
let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2
if case let .groupRcv(member) = ci.chatDir, if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo { case let .group(groupInfo) = chat.chatInfo {
let (prevMember, memCount): (GroupMember?, Int) = let (prevMember, memCount): (GroupMember?, Int) =
@@ -1072,53 +795,28 @@ struct ChatView: View {
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil { if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName { if ci.content.showMemberName {
Group { let t = if memCount == 1 && member.memberRole > .member {
if memCount == 1 && member.memberRole > .member { Text(member.memberRole.text + " ").fontWeight(.semibold) + Text(member.displayName)
Group {
if #available(iOS 16.0, *) {
MemberLayout(spacing: 16, msgWidth: msgWidth) {
Text(member.chatViewName)
.lineLimit(1)
Text(member.memberRole.text)
.fontWeight(.semibold)
.lineLimit(1)
.padding(.trailing, 8)
}
} else {
HStack(spacing: 16) {
Text(member.chatViewName)
.lineLimit(1)
Text(member.memberRole.text)
.fontWeight(.semibold)
.lineLimit(1)
.layoutPriority(1)
}
}
}
.frame(
maxWidth: maxWidth,
alignment: chatItem.chatDir.sent ? .trailing : .leading
)
} else { } else {
Text(memberNames(member, prevMember, memCount)) Text(memberNames(member, prevMember, memCount))
.lineLimit(2)
}
} }
t
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0)) .padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
.padding(.top, 3) // this is in addition to message sequence gap .padding(.top, 7)
} }
HStack(alignment: .center, spacing: 0) { HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf { if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.trailing, 12) .padding(.trailing, 12)
} }
HStack(alignment: .top, spacing: 10) { HStack(alignment: .top, spacing: 8) {
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background) MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture { .onTapGesture {
if let member = m.getGroupMember(member.groupMemberId) { if m.membersLoaded {
selectedMember = member selectedMember = m.getGroupMember(member.groupMemberId)
} else { } else {
Task { Task {
await m.loadGroupMembers(groupInfo) { await m.loadGroupMembers(groupInfo) {
@@ -1127,12 +825,14 @@ struct ChatView: View {
} }
} }
} }
chatItemWithMenu(ci, range, maxWidth, itemSeparation) .appSheet(item: $selectedMember) { member in
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
chatItemWithMenu(ci, range, maxWidth)
} }
} }
} }
.padding(.bottom, bottomPadding) .padding(.bottom, 5)
.padding(.trailing) .padding(.trailing)
.padding(.leading, 12) .padding(.leading, 12)
} else { } else {
@@ -1141,11 +841,11 @@ struct ChatView: View {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12) .padding(.leading, 12)
} }
chatItemWithMenu(ci, range, maxWidth, itemSeparation) chatItemWithMenu(ci, range, maxWidth)
.padding(.trailing) .padding(.trailing)
.padding(.leading, 10 + memberImageSize + 12) .padding(.leading, memberImageSize + 8 + 12)
} }
.padding(.bottom, bottomPadding) .padding(.bottom, 5)
} }
} else { } else {
HStack(alignment: .center, spacing: 0) { HStack(alignment: .center, spacing: 0) {
@@ -1158,10 +858,10 @@ struct ChatView: View {
.padding(.leading) .padding(.leading)
} }
} }
chatItemWithMenu(ci, range, maxWidth, itemSeparation) chatItemWithMenu(ci, range, maxWidth)
.padding(.horizontal) .padding(.horizontal)
} }
.padding(.bottom, bottomPadding) .padding(.bottom, 5)
} }
} }
@@ -1176,18 +876,17 @@ struct ChatView: View {
} }
} }
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
VStack(alignment: alignment.horizontal, spacing: 3) { VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView( ChatItemView(
chat: chat, chat: chat,
chatItem: ci, chatItem: ci,
maxWidth: maxWidth, maxWidth: maxWidth,
revealed: .constant(revealed),
allowMenu: $allowMenu allowMenu: $allowMenu
) )
.environment(\.revealed, revealed) .modifier(ChatItemClipped(ci))
.environment(\.showTimestamp, itemSeparation.timestamp)
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
.accessibilityLabel("") .accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
@@ -1217,6 +916,14 @@ struct ChatView: View {
}) { }) {
ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo) ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo)
} }
.sheet(isPresented: $showForwardingSheet) {
if #available(iOS 16.0, *) {
ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState)
.presentationDetents([.fraction(0.8)])
} else {
ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState)
}
}
} }
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
@@ -1357,7 +1064,7 @@ struct ChatView: View {
var forwardButton: Button<some View> { var forwardButton: Button<some View> {
Button { Button {
forwardedChatItems = [chatItem] showForwardingSheet = true
} label: { } label: {
Label( Label(
NSLocalizedString("Forward", comment: "chat item action"), NSLocalizedString("Forward", comment: "chat item action"),
@@ -18,7 +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
imageFromBase64(image) UIImage(base64Encoded: image)
} }
if imgs.count == 0 { if imgs.count == 0 {
ProgressView() ProgressView()
@@ -40,7 +40,7 @@ struct ComposeLinkView: View {
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 uiImage = imageFromBase64(linkPreview.image) { if let uiImage = UIImage(base64Encoded: linkPreview.image) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
@@ -55,7 +55,7 @@ struct ComposeLinkView: View {
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
} }
.padding(.vertical, 5) .padding(.vertical, 5)
.frame(maxWidth: .infinity, minHeight: 60) .frame(maxWidth: .infinity, minHeight: 60, maxHeight: 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 forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo) case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo)
} }
enum VoiceMessageRecordingState { enum VoiceMessageRecordingState {
@@ -73,10 +73,10 @@ struct ComposeState {
} }
} }
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) { init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) {
self.message = "" self.message = ""
self.preview = .noPreview self.preview = .noPreview
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo) self.contextItem = .forwardingItem(chatItem: forwardingItem, 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 .forwardingItems: return true case .forwardingItem: return true
default: return false default: return false
} }
} }
@@ -167,13 +167,6 @@ 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 {
@@ -287,8 +280,8 @@ struct ComposeView: View {
// this is a workaround to fire an explicit event in certain cases // this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false @State private var stopPlayback: Bool = false
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -389,11 +382,7 @@ struct ComposeView: View {
} }
} }
} }
.background { .background(ToolbarMaterial.material(toolbarMaterial))
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 {
@@ -459,7 +448,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 = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { if let img = 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))
@@ -551,7 +540,7 @@ struct ComposeView: View {
} }
private func addMediaContent(_ content: UploadContent) async { private func addMediaContent(_ content: UploadContent) async {
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { if let img = 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))
@@ -694,7 +683,7 @@ struct ComposeView: View {
case let .quotedItem(chatItem: quotedItem): case let .quotedItem(chatItem: quotedItem):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItems: [quotedItem], contextItem: quotedItem,
contextIcon: "arrowshape.turn.up.left", contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
) )
@@ -702,17 +691,18 @@ struct ComposeView: View {
case let .editingItem(chatItem: editingItem): case let .editingItem(chatItem: editingItem):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItems: [editingItem], contextItem: editingItem,
contextIcon: "pencil", contextIcon: "pencil",
cancelContextItem: { clearState() } cancelContextItem: { clearState() }
) )
Divider() Divider()
case let .forwardingItems(chatItems, _): case let .forwardingItem(chatItem: forwardedItem, _):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItems: chatItems, contextItem: forwardedItem,
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() Divider()
} }
@@ -736,11 +726,10 @@ struct ComposeView: View {
} }
if chat.chatInfo.contact?.nextSendGrpInv ?? false { if chat.chatInfo.contact?.nextSendGrpInv ?? false {
await sendMemberContactInvitation() await sendMemberContactInvitation()
} else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem { } else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem {
// Composed text is send as a reply to the last forwarded item sent = await forwardItem(ci, fromChatInfo, ttl)
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
if !composeState.message.isEmpty { if !composeState.message.isEmpty {
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl) sent = 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)
@@ -757,28 +746,26 @@ 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(media): case let .mediaPreviews(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 i > 0 { if case (_, .video(_, _, _)) = media[i] {
// Sleep to allow `progressByTimeout` update be rendered sent = await sendVideo(media[i], ttl: ttl)
try? await Task.sleep(nanoseconds: 100_000000) } else {
sent = await sendImage(media[i], ttl: ttl)
} }
if let (fileSource, msgContent) = mediaContent(media[i], text: "") { _ = try? await Task.sleep(nanoseconds: 100_000000)
msgs.append(ComposedMessage(fileSource: fileSource, msgContent: msgContent)) }
if case (_, .video(_, _, _)) = media[last] {
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
} else {
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
} }
} }
if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) { if sent == nil {
msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent)) sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
} }
}
if msgs.isEmpty {
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)
@@ -800,20 +787,6 @@ 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 }
} }
@@ -877,6 +850,23 @@ 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) {
ChatModel.shared.filesToDelete.remove(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)
@@ -893,66 +883,44 @@ 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? {
await send( if let chatItem = chat.chatInfo.chatType == .local
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)], ? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc)
live: live, : await apiSendMessage(
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)
for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem) chatModel.addChatItem(chat.chatInfo, chatItem)
} }
return chatItem
} }
return chatItems if let file = file {
}
for msg in msgs {
if let file = msg.fileSource {
removeFile(file.filePath) removeFile(file.filePath)
} }
} return nil
return []
} }
func forwardItems(_ forwardedItems: [ChatItem], _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> [ChatItem] { func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
if let chatItems = await apiForwardChatItems( if let chatItem = await apiForwardChatItem(
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,
itemIds: forwardedItems.map { $0.id }, itemId: forwardedItem.id,
ttl: ttl ttl: ttl
) { ) {
await MainActor.run { await MainActor.run {
for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem) chatModel.addChatItem(chat.chatInfo, chatItem)
} }
if forwardedItems.count != chatItems.count { return chatItem
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 chatItems
} else {
return []
} }
return nil
} }
func checkLinkPreview() -> MsgContent { func checkLinkPreview() -> MsgContent {
@@ -969,6 +937,14 @@ 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 {
@@ -12,7 +12,7 @@ import SimpleXChat
struct ContextItemView: View { struct ContextItemView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
let contextItems: [ChatItem] let contextItem: ChatItem
let contextIcon: String let contextIcon: String
let cancelContextItem: () -> Void let cancelContextItem: () -> Void
var showSender: Bool = true var showSender: Bool = true
@@ -24,22 +24,13 @@ struct ContextItemView: View {
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
if let singleItem = contextItems.first, contextItems.count == 1 { if showSender, let sender = contextItem.memberDisplayName {
if showSender, let sender = singleItem.memberDisplayName {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(sender).font(.caption).foregroundColor(theme.colors.secondary) Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
msgContentView(lines: 2, contextItem: singleItem) msgContentView(lines: 2)
} }
} else { } else {
msgContentView(lines: 3, contextItem: singleItem) msgContentView(lines: 3)
}
} else {
Text(
chat.chatInfo.chatType == .local
? "Saving \(contextItems.count) messages"
: "Forwarding \(contextItems.count) messages"
)
.italic()
} }
Spacer() Spacer()
Button { Button {
@@ -54,32 +45,23 @@ struct ContextItemView: View {
.padding(12) .padding(12)
.frame(minHeight: 54) .frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(background) .background(chatItemFrameColor(contextItem, theme))
} }
private var background: Color { private func msgContentView(lines: Int) -> some View {
contextItems.first contextMsgPreview()
.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(_ contextItem: ChatItem) -> Text { private func contextMsgPreview() -> Text {
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) 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 isFileLoaded ? image("doc.fill") : Text("") case .file: return image("doc.fill")
case .image: return image("photo") case .image: return image("photo")
case .voice: return isFileLoaded ? image("play.fill") : Text("") case .voice: return image("play.fill")
default: return Text("") default: return Text("")
} }
} }
@@ -93,6 +75,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, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}) return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
} }
} }
@@ -40,7 +40,7 @@ struct SendMessageView: View {
@State private var showCustomTimePicker = false @State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
@State private var progressByTimeout = false @State private var progressByTimeout = false
@UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View { var body: some View {
ZStack { ZStack {
@@ -227,7 +227,6 @@ 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 {
@@ -105,15 +105,13 @@ struct AddGroupMembersViewCommon: View {
.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 + [dummyContact]) { contact in ForEach(members) { contact in
if contact.contactId != dummyContact.contactId {
contactCheckView(contact) contactCheckView(contact)
} }
} }
} }
} }
} }
}
.frame(maxHeight: .infinity, alignment: .top) .frame(maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alert in .alert(item: $alert) { alert in
switch alert { switch alert {
@@ -132,14 +130,6 @@ struct AddGroupMembersViewCommon: View {
.modifier(ThemedBackground(grouped: true)) .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()
@@ -185,6 +185,7 @@ 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 {
@@ -18,7 +18,6 @@ struct GroupMemberInfoView: View {
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?
@@ -95,8 +94,6 @@ struct GroupMemberInfoView: View {
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
if connectionLoaded {
if member.memberActive { if member.memberActive {
Section { Section {
if let code = connectionCode { verifyCodeButton(code) } if let code = connectionCode { verifyCodeButton(code) }
@@ -201,33 +198,27 @@ struct GroupMemberInfoView: View {
} }
} }
} }
}
} }
.navigationBarHidden(true) .navigationBarHidden(true)
.task { .onAppear {
if #unavailable(iOS 16) { if #unavailable(iOS 16) {
// this condition prevents re-setting picker // this condition prevents re-setting picker
if !justOpened { return } if !justOpened { return }
} }
justOpened = false justOpened = false
DispatchQueue.main.async {
newRole = member.memberRole newRole = member.memberRole
do { do {
let (_, stats) = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (mem, code) = member.memberActive ? try await apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
await MainActor.run {
_ = chatModel.upsertGroupMember(groupInfo, mem) _ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats connectionStats = stats
connectionCode = code connectionCode = code
connectionLoaded = true
}
} catch let error { } catch let error {
await MainActor.run {
connectionLoaded = true
}
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))") logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
} }
} }
}
.onChange(of: newRole) { newRole in .onChange(of: newRole) { newRole in
if newRole != member.memberRole { if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole) alert = .changeMemberRoleAlert(mem: member, role: newRole)
@@ -330,27 +321,27 @@ struct GroupMemberInfoView: View {
func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View { func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { InfoViewButton(image: "message.fill", title: "message", width: width) {
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
} }
} }
} }
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View { func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { InfoViewButton(image: "message.fill", title: "message", width: width) {
Task {
do { do {
let chat = try await apiGetChat(type: .direct, id: contactId) let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat) chatModel.addChat(chat)
ItemsModel.shared.loadOpenChat(chat.id) {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
} }
} catch let error { } catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))") logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
} }
} }
} }
}
func createMemberContactButton(width: CGFloat) -> some View { func createMemberContactButton(width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { InfoViewButton(image: "message.fill", title: "message", width: width) {
@@ -361,10 +352,9 @@ 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)))
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
} chatModel.chatId = memberContact.id
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected) chatModel.setContactNetworkStatus(memberContact, .connected)
} }
} catch let error { } catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))") logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
@@ -110,13 +110,10 @@ struct GroupProfileView: View {
} }
} }
.onChange(of: chosenImage) { image in .onChange(of: chosenImage) { image in
Task { if let image = image {
let resized: String? = if let image { groupProfile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
} else { } else {
nil groupProfile.image = nil
}
await MainActor.run { groupProfile.image = resized }
} }
} }
.onAppear { .onAppear {
+15 -82
View File
@@ -8,16 +8,15 @@
import SwiftUI import SwiftUI
import Combine import Combine
import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top /// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Content: View>: UIViewControllerRepresentable { struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
let items: Array<ChatItem> let items: Array<Item>
@Binding var scrollState: ReverseListScrollModel.State @Binding var scrollState: ReverseListScrollModel<Item>.State
/// Closure, that returns user interface for a given item /// Closure, that returns user interface for a given item
let content: (ChatItem) -> Content let content: (Item) -> Content
let loadPage: () -> Void let loadPage: () -> Void
@@ -26,9 +25,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
} }
func updateUIViewController(_ controller: Controller, context: Context) { func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty { if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
controller.view.layer.removeAllAnimations()
switch destination { switch destination {
case .nextPage: case .nextPage:
controller.scrollToNextPage() controller.scrollToNextPage()
@@ -45,10 +42,9 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
/// Controller, which hosts SwiftUI cells /// Controller, which hosts SwiftUI cells
class Controller: UITableViewController { class Controller: UITableViewController {
private enum Section { case main } private enum Section { case main }
var representer: ReverseList private let representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>! private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var itemCount: Int = 0 private var itemCount: Int = 0
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>() private var bag = Set<AnyCancellable>()
init(representer: ReverseList) { init(representer: ReverseList) {
@@ -75,10 +71,10 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
} }
// 3. Configure data source // 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>( self.dataSource = UITableViewDiffableDataSource<Section, Item>(
tableView: tableView tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in ) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8 { if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
self.representer.loadPage() self.representer.loadPage()
} }
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath) let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
@@ -107,15 +103,6 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
name: notificationName, name: notificationName,
object: nil 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) @available(*, unavailable)
@@ -165,27 +152,22 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
/// Scrolls to Item at index path /// Scrolls to Item at index path
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil` /// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
func scroll(to index: Int?, position: UITableView.ScrollPosition) { func scroll(to index: Int?, position: UITableView.ScrollPosition) {
if let index {
var animated = false var animated = false
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
animated = true animated = true
} }
if let index, tableView.numberOfRows(inSection: 0) != 0 {
tableView.scrollToRow( tableView.scrollToRow(
at: IndexPath(row: index, section: 0), at: IndexPath(row: index, section: 0),
at: position, at: position,
animated: animated animated: animated
) )
} else {
tableView.setContentOffset(
CGPoint(x: .zero, y: -InvertedTableView.inset),
animated: animated
)
}
Task { representer.scrollState = .atDestination } Task { representer.scrollState = .atDestination }
} }
}
func update(items: [ChatItem]) { func update(items: Array<Item>) {
var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main]) snapshot.appendSections([.main])
snapshot.appendItems(items) snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none dataSource.defaultRowAnimation = .none
@@ -193,50 +175,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
snapshot, snapshot,
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1 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 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 }
} }
} }
@@ -280,19 +219,13 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
} }
} }
typealias ListState = (
scrollOffset: Double,
topItemDate: Date?,
bottomItemId: ChatItem.ID?
)
/// Manages ``ReverseList`` scrolling /// Manages ``ReverseList`` scrolling
class ReverseListScrollModel: ObservableObject { class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
/// Represents Scroll State of ``ReverseList`` /// Represents Scroll State of ``ReverseList``
enum State: Equatable { enum State: Equatable {
enum Destination: Equatable { enum Destination: Equatable {
case nextPage case nextPage
case item(ChatItem.ID) case item(Item.ID)
case bottom case bottom
} }
@@ -310,7 +243,7 @@ class ReverseListScrollModel: ObservableObject {
state = .scrollingTo(.bottom) state = .scrollingTo(.bottom)
} }
func scrollToItem(id: ChatItem.ID) { func scrollToItem(id: Item.ID) {
state = .scrollingTo(.item(id)) state = .scrollingTo(.item(id))
} }
} }
@@ -32,17 +32,13 @@ struct SelectedItemsBottomToolbar: View {
var deleteItems: (Bool) -> Void var deleteItems: (Bool) -> Void
var moderateItems: () -> Void var moderateItems: () -> Void
//var shareItems: () -> Void //var shareItems: () -> Void
var forwardItems: () -> Void
@State var deleteEnabled: Bool = false @State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false @State var deleteForEveryoneEnabled: Bool = false
@State var canModerate: Bool = false @State var canModerate: Bool = false
@State var moderateEnabled: Bool = false @State var moderateEnabled: Bool = false
@State var forwardEnabled: Bool = false @State var allButtonsDisabled = false
@State var deleteCountProhibited = false
@State var forwardCountProhibited = false
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -54,11 +50,10 @@ struct SelectedItemsBottomToolbar: View {
} label: { } label: {
Image(systemName: "trash") Image(systemName: "trash")
.resizable() .resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center) .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || deleteCountProhibited ? theme.colors.secondary: .red) .foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
} }
.disabled(!deleteEnabled || deleteCountProhibited) .disabled(!deleteEnabled || allButtonsDisabled)
Spacer() Spacer()
Button { Button {
@@ -66,24 +61,24 @@ struct SelectedItemsBottomToolbar: View {
} label: { } label: {
Image(systemName: "flag") Image(systemName: "flag")
.resizable() .resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center) .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red) .foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
} }
.disabled(!moderateEnabled || deleteCountProhibited) .disabled(!moderateEnabled || allButtonsDisabled)
.opacity(canModerate ? 1 : 0) .opacity(canModerate ? 1 : 0)
Spacer() Spacer()
Button { Button {
forwardItems() //shareItems()
} label: { } label: {
Image(systemName: "arrowshape.turn.up.forward") Image(systemName: "square.and.arrow.up")
.resizable() .resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center) .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary) .foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
} }
.disabled(!forwardEnabled || forwardCountProhibited) .disabled(allButtonsDisabled)
.opacity(0)
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12) .padding([.leading, .trailing], 12)
@@ -106,22 +101,20 @@ struct SelectedItemsBottomToolbar: View {
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) { private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
let count = selectedItems?.count ?? 0 let count = selectedItems?.count ?? 0
deleteCountProhibited = count == 0 || count > 200 allButtonsDisabled = count == 0 || count > 20
forwardCountProhibited = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo) canModerate = possibleToModerate(chatInfo)
if let selected = selectedItems { if let selected = selectedItems {
let me: Bool let me: Bool
let onlyOwnGroupItems: Bool let onlyOwnGroupItems: Bool
(deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) { if selected.contains(ci.id) {
var (de, dee, me, onlyOwnGroupItems, fe, sel) = r var (de, dee, me, onlyOwnGroupItems, sel) = r
de = de && ci.canBeDeletedForSelf de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil 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 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) return (de, dee, me, onlyOwnGroupItems, sel)
} else { } else {
return r return r
} }
@@ -10,7 +10,7 @@ import SwiftUI
struct ChatHelp: View { struct ChatHelp: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
let dismissSettingsSheet: DismissAction @Binding var showSettings: Bool
var body: some View { var body: some View {
ScrollView { chatHelp() } ScrollView { chatHelp() }
@@ -23,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.") {
dismissSettingsSheet() showSettings = false
DispatchQueue.main.async { DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL) UIApplication.shared.open(simplexTeamURL)
} }
@@ -61,9 +61,8 @@ 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 {
ChatHelp(dismissSettingsSheet: mockDismiss) @State var showSettings = false
return ChatHelp(showSettings: $showSettings)
} }
} }
@@ -114,7 +114,7 @@ struct ChatListNavLink: View {
} }
} else { } else {
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: 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)) }
) )
@@ -194,7 +194,7 @@ struct ChatListNavLink: View {
} }
default: default:
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: 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
@@ -221,7 +221,7 @@ struct ChatListNavLink: View {
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { @ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, tag: 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
@@ -355,7 +355,6 @@ struct ChatListNavLink: View {
.tint(.red) .tint(.red)
} }
.frame(height: dynamicRowHeight) .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) } }
@@ -393,7 +392,6 @@ struct ChatListNavLink: View {
} }
} }
} }
.contentShape(Rectangle())
.onTapGesture { .onTapGesture {
showContactConnectionInfo = true showContactConnectionInfo = true
} }
@@ -474,7 +472,9 @@ struct ChatListNavLink: View {
Task { Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
if ok { if ok {
ItemsModel.shared.loadOpenChat(contact.id) await MainActor.run {
chatModel.chatId = contact.id
}
AlertManager.shared.showAlert(connReqSentAlert(.contact)) AlertManager.shared.showAlert(connReqSentAlert(.contact))
} }
} }
+49 -137
View File
@@ -9,87 +9,18 @@
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
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var activeUserPickerSheet: UserPickerSheet? @Binding var showSettings: Bool
@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 userPickerVisible = false
@State private var showConnectDesktop = false
@State private var scrollToSearchBar = false @State private var scrollToSearchBar = false
@State private var userPickerShown: Bool = 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(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@@ -115,23 +46,21 @@ struct ChatListView: View {
), ),
destination: chatView destination: chatView
) { chatListView } ) { chatListView }
if userPickerVisible {
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
withAnimation {
userPickerVisible.toggle()
} }
.modifier(
Sheet(isPresented: $userPickerShown) {
UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet)
} }
}
UserPicker(
showSettings: $showSettings,
showConnectDesktop: $showConnectDesktop,
userPickerVisible: $userPickerVisible
) )
.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
}
} }
.sheet(isPresented: $showConnectDesktop) {
ConnectDesktopView()
} }
} }
@@ -144,7 +73,7 @@ struct ChatListView: View {
.navigationBarHidden(searchMode || oneHandUI) .navigationBarHidden(searchMode || oneHandUI)
} }
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.onDisappear() { activeUserPickerSheet = nil } .onDisappear() { withAnimation { userPickerVisible = false } }
.refreshable { .refreshable {
AlertManager.shared.showAlert(Alert( AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"), title: Text("Reconnect servers?"),
@@ -235,7 +164,7 @@ struct ChatListView: View {
let user = chatModel.currentUser ?? User.sampleData let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel)) ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
.padding([.top, .trailing], 3) .padding(.trailing, 4)
let allRead = chatModel.users let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden } .filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 } .allSatisfy { u in u.unreadCount == 0 }
@@ -244,7 +173,13 @@ struct ChatListView: View {
} }
} }
.onTapGesture { .onTapGesture {
userPickerShown = true if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
withAnimation {
userPickerVisible.toggle()
}
} else {
showSettings = true
}
} }
} }
@@ -282,7 +217,6 @@ struct ChatListView: View {
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
} }
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat) ChatListNavLink(chat: chat)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
@@ -291,22 +225,6 @@ struct ChatListView: View {
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
} }
.offset(x: -8) .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))
}
}
} }
.listStyle(.plain) .listStyle(.plain)
.onChange(of: chatModel.chatId) { currentChatId in .onChange(of: chatModel.chatId) { currentChatId in
@@ -334,7 +252,7 @@ struct ChatListView: View {
} }
} }
private func unreadBadge(size: CGFloat = 18) -> some View { private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
Circle() Circle()
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
@@ -406,7 +324,7 @@ struct ChatListView: View {
struct SubsStatusIndicator: View { struct SubsStatusIndicator: View {
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs @State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
@State private var hasSess: Bool = false @State private var hasSess: Bool = false
@State private var task: Task<Void, Never>? @State private var timer: Timer? = nil
@State private var showServersSummary = false @State private var showServersSummary = false
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false @AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
@@ -423,12 +341,11 @@ struct SubsStatusIndicator: View {
} }
} }
} }
.disabled(ChatModel.shared.chatRunning != true)
.onAppear { .onAppear {
startTask() startTimer()
} }
.onDisappear { .onDisappear {
stopTask() stopTimer()
} }
.appSheet(isPresented: $showServersSummary) { .appSheet(isPresented: $showServersSummary) {
ServersSummaryView() ServersSummaryView()
@@ -436,28 +353,25 @@ struct SubsStatusIndicator: View {
} }
} }
private func startTask() { private func startTimer() {
task = Task { timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
while !Task.isCancelled { if AppChatState.shared.value == .active {
if AppChatState.shared.value == .active, ChatModel.shared.chatRunning == true { getSubsTotal()
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() { func stopTimer() {
task?.cancel() timer?.invalidate()
task = nil timer = nil
}
private func getSubsTotal() {
do {
(subs, hasSess) = try getAgentSubsTotal()
} catch let error {
logger.error("getSubsTotal error: \(responseError(error))")
}
} }
} }
@@ -581,29 +495,27 @@ 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.updateChats([ chatModel.chats = [
ChatData( Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
), ),
ChatData( Chat(
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.")]
), ),
ChatData( Chat(
chatInfo: ChatInfo.sampleData.contactRequest, chatInfo: ChatInfo.sampleData.contactRequest,
chatItems: [] chatItems: []
) )
]) ]
return Group { return Group {
ChatListView(activeUserPickerSheet: $userPickerSheet) ChatListView(showSettings: Binding.constant(false))
.environmentObject(chatModel) .environmentObject(chatModel)
ChatListView(activeUserPickerSheet: $userPickerSheet) ChatListView(showSettings: Binding.constant(false))
.environmentObject(ChatModel()) .environmentObject(ChatModel())
} }
} }
@@ -35,16 +35,11 @@ struct ChatPreviewView: View {
} }
.padding(.leading, 4) .padding(.leading, 4)
let chatTs = if let cItem {
cItem.meta.itemTs
} else {
chat.chatInfo.chatTs
}
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(alignment: .top) { HStack(alignment: .top) {
chatPreviewTitle() chatPreviewTitle()
Spacer() Spacer()
(formatTimestampText(chatTs)) (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
.font(.subheadline) .font(.subheadline)
.frame(minWidth: 60, alignment: .trailing) .frame(minWidth: 60, alignment: .trailing)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
@@ -307,7 +302,7 @@ struct ChatPreviewView: View {
case let .link(_, preview): case let .link(_, preview):
smallContentPreview(size: dynamicMediaSize) { smallContentPreview(size: dynamicMediaSize) {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
Image(uiImage: imageFromBase64(preview.image) ?? UIImage(systemName: "arrow.up.right")!) Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: dynamicMediaSize, height: dynamicMediaSize) .frame(width: dynamicMediaSize, height: dynamicMediaSize)
@@ -328,13 +323,13 @@ struct ChatPreviewView: View {
} }
case let .image(_, image): case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) { smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel()) .environmentObject(ReverseListScrollModel<ChatItem>())
} }
case let .video(_,image, duration): case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) { smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel()) .environmentObject(ReverseListScrollModel<ChatItem>())
} }
case let .voice(_, duration): case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) { smallContentPreviewVoice(size: dynamicMediaSize) {
@@ -381,7 +376,17 @@ struct ChatPreviewView: View {
switch chat.chatInfo { switch chat.chatInfo {
case let .direct(contact): case let .direct(contact):
if contact.active && contact.activeConn != nil { if contact.active && contact.activeConn != nil {
NetworkStatusView(contact: contact, size: size) switch (chatModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.foregroundColor(theme.colors.secondary)
default:
ProgressView()
}
} else { } else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
} }
@@ -395,30 +400,6 @@ struct ChatPreviewView: View {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
} }
} }
struct NetworkStatusView: View {
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@EnvironmentObject var theme: AppTheme
@ObservedObject var networkModel = NetworkModel.shared
let contact: Contact
let size: CGFloat
var body: some View {
let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize
switch (networkModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(contact.contactConnIncognito, theme.colors.secondary, size: size)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.foregroundColor(theme.colors.secondary)
default:
ProgressView()
}
}
}
} }
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View { @ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View {
@@ -16,6 +16,7 @@ struct ContactConnectionView: View {
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@State private var localAlias = "" @State private var localAlias = ""
@FocusState private var aliasTextFieldFocused: Bool @FocusState private var aliasTextFieldFocused: Bool
@State private var showContactConnectionInfo = false
var body: some View { var body: some View {
if case let .contactConnection(conn) = chat.chatInfo { if case let .contactConnection(conn) = chat.chatInfo {
@@ -31,6 +32,7 @@ struct ContactConnectionView: View {
.scaledToFill() .scaledToFill()
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme)) .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
.onTapGesture { showContactConnectionInfo = true }
} }
.frame(width: 63, height: 63) .frame(width: 63, height: 63)
.padding(.leading, 4) .padding(.leading, 4)
@@ -70,6 +72,9 @@ struct ContactConnectionView: View {
Spacer() Spacer()
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.appSheet(isPresented: $showContactConnectionInfo) {
ContactConnectionInfo(contactConnection: contactConnection)
}
} }
} }
} }
@@ -407,18 +407,12 @@ struct ServersSummaryView: View {
struct SubscriptionStatusIndicatorView: View { struct SubscriptionStatusIndicatorView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs var subs: SMPServerSubs
var hasSess: Bool var hasSess: Bool
var body: some View { var body: some View {
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage( let onionHosts = networkUseOnionHostsGroupDefault.get()
online: m.networkInfo.online, let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
subs: subs,
hasSess: hasSess,
primaryColor: theme.colors.primary
)
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue) Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
.foregroundColor(color) .foregroundColor(color)
@@ -431,32 +425,26 @@ struct SubscriptionStatusIndicatorView: View {
struct SubscriptionStatusPercentageView: View { struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs var subs: SMPServerSubs
var hasSess: Bool var hasSess: Bool
var body: some View { var body: some View {
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage( let onionHosts = networkUseOnionHostsGroupDefault.get()
online: m.networkInfo.online, let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
subs: subs,
hasSess: hasSess,
primaryColor: theme.colors.primary
)
Text(verbatim: "\(Int(floor(statusPercent * 100)))%") Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
.foregroundColor(.secondary) .foregroundColor(.secondary)
.font(.caption) .font(.caption)
} }
} }
func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double, Double) { func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double { func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1 n >= 1 ? 1
: n <= 0 ? 0 : n <= 0 ? 0
: (n * 4).rounded() / 4 : (n * 4).rounded() / 4
} }
let activeColor: Color = usesProxy ? .indigo : primaryColor let activeColor: Color = onionHosts == .require ? .indigo : .accentColor
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0) let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
let activeSubsRounded = roundedToQuarter(subs.shareOfActive) let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
+126 -225
View File
@@ -8,275 +8,176 @@ import SimpleXChat
struct UserPicker: View { struct UserPicker: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.scenePhase) var scenePhase
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize @Binding var showSettings: Bool
@Environment(\.scenePhase) private var scenePhase: ScenePhase @Binding var showConnectDesktop: Bool
@Environment(\.colorScheme) private var colorScheme: ColorScheme @Binding var userPickerVisible: Bool
@Binding var userPickerShown: Bool @State var scrollViewContentSize: CGSize = .zero
@Binding var activeSheet: UserPickerSheet? @State var disableScrolling: Bool = true
@State private var currentUser: Int64? private let menuButtonHeight: CGFloat = 68
@State private var switchingProfile = false @State var chatViewNameWidth: CGFloat = 0
@State private var frameWidth: CGFloat = 0
@State private var resetScroll = ResetScrollAction()
// Inset grouped list dimensions
private let imageSize: CGFloat = 44
private let rowPadding: CGFloat = 16
private let rowVerticalPadding: CGFloat = 11
private let sectionSpacing: CGFloat = 35
private var sectionHorizontalPadding: CGFloat { frameWidth > 375 ? 20 : 16 }
private let sectionShape = RoundedRectangle(cornerRadius: 10, style: .continuous)
var body: some View { var body: some View {
let otherUsers: [UserInfo] = m.users VStack {
.filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId } Spacer().frame(height: 1)
.sorted(using: KeyPathComparator<UserInfo>(\.user.activeOrder, order: .reverse))
let sectionWidth = max(frameWidth - sectionHorizontalPadding * 2, 0)
let currentUserWidth = max(frameWidth - sectionHorizontalPadding - rowPadding * 2 - 14 - imageSize, 0)
let stopped = m.chatRunning != true
VStack(spacing: sectionSpacing) {
if let user = m.currentUser {
StickyScrollView(resetScroll: $resetScroll) {
HStack(spacing: rowPadding) {
HStack {
ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground))
.padding(.trailing, 6)
profileName(user).lineLimit(1)
}
.padding(rowPadding)
.frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading)
.modifier(ListRow { activeSheet = .currentProfile })
.clipShape(sectionShape)
.disabled(stopped)
.opacity(stopped ? 0.4 : 1)
ForEach(otherUsers) { u in
userView(u, size: imageSize)
.frame(maxWidth: sectionWidth * 0.618)
.fixedSize()
.disabled(stopped)
.opacity(stopped ? 0.4 : 1)
}
}
.padding(.horizontal, sectionHorizontalPadding)
}
.frame(height: 2 * rowPadding + imageSize)
.padding(.top, sectionSpacing)
.overlay(DetermineWidth())
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
}
VStack(spacing: 0) { VStack(spacing: 0) {
openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address, disabled: stopped) ScrollView {
openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences, disabled: stopped) ScrollViewReader { sp in
openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles, disabled: stopped) let users = m.users
openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop, disabled: stopped) .filter({ u in u.user.activeUser || !u.user.hidden })
ZStack(alignment: .trailing) { .sorted { u, _ in u.user.activeUser }
openSheetOnTap("gearshape", title: "Settings", sheet: .settings, showDivider: false) VStack(spacing: 0) {
Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill") ForEach(users) { u in
.resizable() userView(u)
.scaledToFit() Divider()
.symbolRenderingMode(.monochrome) if u.user.activeUser { Divider() }
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: 20, maxHeight: .infinity)
.padding(.horizontal, rowPadding)
.background(Color(.systemBackground).opacity(0.01))
.onTapGesture {
if (colorScheme == .light) {
ThemeManager.applyTheme(systemDarkThemeDefault.get())
} else {
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
} }
} }
.onLongPressGesture { .overlay {
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) GeometryReader { geo -> Color in
DispatchQueue.main.async {
scrollViewContentSize = geo.size
let scenes = UIApplication.shared.connectedScenes
if let windowScene = scenes.first as? UIWindowScene {
let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame
disableScrolling = scrollViewContentSize.height + menuButtonHeight + 10 < layoutFrame.height
}
}
return Color.clear
}
}
.onChange(of: userPickerVisible) { visible in
if visible, let u = users.first {
sp.scrollTo(u.id)
} }
} }
} }
.clipShape(sectionShape)
.padding(.horizontal, sectionHorizontalPadding)
.padding(.bottom, sectionSpacing)
} }
.simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000))
.frame(maxHeight: scrollViewContentSize.height)
menuButton("Use from desktop", icon: "desktopcomputer") {
showConnectDesktop = true
withAnimation {
userPickerVisible.toggle()
}
}
Divider()
menuButton("Settings", icon: "gearshape") {
showSettings = true
withAnimation {
userPickerVisible.toggle()
}
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 16))
.background(
Rectangle()
.fill(theme.colors.surface)
.cornerRadius(16)
.shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0)
)
.onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 }
.frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300)
.padding(8)
.opacity(userPickerVisible ? 1.0 : 0.0)
.onAppear { .onAppear {
do {
// This check prevents the call of listUsers after the app is suspended, and the database is closed. // This check prevents the call of listUsers after the app is suspended, and the database is closed.
if case .active = scenePhase { if case .active = scenePhase {
currentUser = m.currentUser?.userId m.users = try listUsers()
Task {
do {
let users = try await listUsersAsync()
await MainActor.run {
m.users = users
currentUser = m.currentUser?.userId
} }
} catch { } catch let error {
logger.error("Error loading users \(responseError(error))") logger.error("Error loading users \(responseError(error))")
} }
} }
} }
}
.onChange(of: userPickerShown) {
if !$0 { resetScroll() }
}
.modifier(ThemedBackground(grouped: true))
.disabled(switchingProfile)
}
private func userView(_ u: UserInfo, size: CGFloat) -> some View { private func userView(_ u: UserInfo) -> some View {
HStack { let user = u.user
ZStack(alignment: .topTrailing) { return Button(action: {
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground)) if user.activeUser {
if (u.unreadCount > 0) { showSettings = true
unreadBadge(u).offset(x: 4, y: -4) withAnimation {
userPickerVisible.toggle()
} }
} } else {
.padding(.trailing, 6)
Text(u.user.displayName).font(.title2).lineLimit(1)
}
.padding(rowPadding)
.modifier(ListRow {
switchingProfile = true
Task { Task {
do { do {
try await changeActiveUserAsync_(u.user.userId, viewPwd: nil) try await changeActiveUserAsync_(user.userId, viewPwd: nil)
await MainActor.run { await MainActor.run { userPickerVisible = false }
switchingProfile = false
userPickerShown = false
}
} catch { } catch {
await MainActor.run { await MainActor.run {
switchingProfile = false AlertManager.shared.showAlertMsg(
showAlert( title: "Error switching profile!",
NSLocalizedString("Error switching profile!", comment: "alertTitle"), message: "Error: \(responseError(error))"
message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
) )
} }
} }
} }
}
}, label: {
HStack(spacing: 0) {
ProfileImage(imageStr: user.image, size: 44, color: Color(uiColor: .tertiarySystemFill))
.padding(.trailing, 12)
Text(user.chatViewName)
.fontWeight(user.activeUser ? .medium : .regular)
.foregroundColor(theme.colors.onBackground)
.overlay(DetermineWidth())
Spacer()
if user.activeUser {
Image(systemName: "checkmark")
} else if u.unreadCount > 0 {
unreadCounter(u.unreadCount, color: user.showNtfs ? theme.colors.primary : theme.colors.secondary)
} else if !user.showNtfs {
Image(systemName: "speaker.slash")
}
}
.padding(.trailing)
.padding([.leading, .vertical], 12)
}) })
.clipShape(sectionShape) .buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
} }
private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet, showDivider: Bool = true, disabled: Bool = false) -> some View { private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View {
ZStack(alignment: .bottom) { Button(action: action) {
settingsRow(icon, color: theme.colors.secondary) { HStack(spacing: 0) {
Text(title).foregroundColor(.primary).opacity(disabled ? 0.4 : 1) Text(title)
} .overlay(DetermineWidth())
.frame(maxWidth: .infinity, alignment: .leading) Spacer()
.padding(.horizontal, rowPadding) Image(systemName: icon)
.padding(.vertical, rowVerticalPadding) .symbolRenderingMode(.monochrome)
.modifier(ListRow { activeSheet = sheet }) .foregroundColor(theme.colors.secondary)
.disabled(disabled)
if showDivider {
Divider().padding(.leading, 52)
} }
.padding(.horizontal)
.padding(.vertical, 22)
.frame(height: menuButtonHeight)
} }
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
} }
}
private func unreadBadge(_ u: UserInfo) -> some View { private func unreadCounter(_ unread: Int, color: Color) -> some View {
let size = dynamicSize(userFont).chatInfoSize unreadCountText(unread)
return unreadCountText(u.unreadCount) .font(.caption)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding) .padding(.horizontal, 4)
.frame(minWidth: size, minHeight: size) .frame(minWidth: 18, minHeight: 18)
.background(u.user.showNtfs ? theme.colors.primary : theme.colors.secondary) .background(color)
.cornerRadius(dynamicSize(userFont).unreadCorner) .cornerRadius(10)
}
} }
struct ListRow: ViewModifier {
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@State private var touchDown = false
let action: () -> Void
func body(content: Content) -> some View {
ZStack {
elevatedSecondarySystemGroupedBackground
Color(.systemGray4).opacity(touchDown ? 1 : 0)
content
TouchOverlay(touchDown: $touchDown, action: action)
}
}
var elevatedSecondarySystemGroupedBackground: Color {
switch colorScheme {
case .dark: Color(0xFF2C2C2E)
default: Color(0xFFFFFFFF)
}
}
struct TouchOverlay: UIViewRepresentable {
@Binding var touchDown: Bool
let action: () -> Void
func makeUIView(context: Context) -> TouchView {
let touchView = TouchView()
let gesture = UILongPressGestureRecognizer(
target: touchView,
action: #selector(touchView.longPress(gesture:))
)
gesture.delegate = touchView
gesture.minimumPressDuration = 0
touchView.addGestureRecognizer(gesture)
return touchView
}
func updateUIView(_ touchView: TouchView, context: Context) {
touchView.representer = self
}
class TouchView: UIView, UIGestureRecognizerDelegate {
var representer: TouchOverlay?
private var startLocation: CGPoint?
private var task: Task<Void, Never>?
@objc
func longPress(gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
startLocation = gesture.location(in: nil)
task = Task {
do {
try await Task.sleep(nanoseconds: 200_000000)
await MainActor.run { representer?.touchDown = true }
} catch { }
}
case .ended:
if hitTest(gesture.location(in: self), with: nil) == self {
representer?.action()
}
task?.cancel()
representer?.touchDown = false
case .changed:
if let startLocation {
let location = gesture.location(in: nil)
let dx = location.x - startLocation.x
let dy = location.y - startLocation.y
if sqrt(pow(dx, 2) + pow(dy, 2)) > 10 { gesture.state = .failed }
}
case .cancelled, .failed:
task?.cancel()
representer?.touchDown = false
default: break
}
}
func gestureRecognizer(
_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith: UIGestureRecognizer
) -> Bool { true }
}
}
}
struct UserPicker_Previews: PreviewProvider { struct UserPicker_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
@State var activeSheet: UserPickerSheet?
let m = ChatModel() let m = ChatModel()
m.users = [UserInfo.sampleData, UserInfo.sampleData] m.users = [UserInfo.sampleData, UserInfo.sampleData]
return UserPicker( return UserPicker(
userPickerShown: .constant(true), showSettings: Binding.constant(false),
activeSheet: $activeSheet showConnectDesktop: Binding.constant(false),
userPickerVisible: Binding.constant(true)
) )
.environmentObject(m) .environmentObject(m)
} }
@@ -56,7 +56,7 @@ struct ContactListNavLink: View {
func recentContactNavLink(_ contact: Contact) -> some View { func recentContactNavLink(_ contact: Contact) -> some View {
Button { Button {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(contact.id) ChatModel.shared.chatId = contact.id
} }
} label: { } label: {
contactPreview(contact, titleColor: theme.colors.onBackground) contactPreview(contact, titleColor: theme.colors.onBackground)
@@ -82,8 +82,11 @@ struct ContactListNavLink: View {
Button { Button {
Task { Task {
await MainActor.run { await MainActor.run {
var updatedContact = contact
updatedContact.chatDeleted = false
ChatModel.shared.updateContact(updatedContact)
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(contact.id) ChatModel.shared.chatId = contact.id
} }
} }
} }
@@ -188,7 +191,9 @@ struct ContactListNavLink: View {
Task { Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") }) let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
if ok { if ok {
ItemsModel.shared.loadOpenChat(contact.id) await MainActor.run {
ChatModel.shared.chatId = contact.id
}
DispatchQueue.main.async { DispatchQueue.main.async {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(.contact)) AlertManager.shared.showAlert(connReqSentAlert(.contact))
@@ -44,7 +44,7 @@ enum DatabaseAlert: Identifiable {
struct DatabaseView: View { struct DatabaseView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
let dismissSettingsSheet: DismissAction @Binding var showSettings: Bool
@State private var runChat = false @State private var runChat = false
@State private var alert: DatabaseAlert? = nil @State private var alert: DatabaseAlert? = nil
@State private var showFileImporter = false @State private var showFileImporter = false
@@ -270,12 +270,12 @@ struct DatabaseView: View {
case let .archiveImportedWithErrors(errs): case let .archiveImportedWithErrors(errs):
return Alert( return Alert(
title: Text("Chat database imported"), title: Text("Chat database imported"),
message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs) message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs)
) )
case let .archiveExportedWithErrors(archivePath, errs): case let .archiveExportedWithErrors(archivePath, errs):
return Alert( return Alert(
title: Text("Chat database exported"), title: Text("Chat database exported"),
message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs), message: Text("You may save the exported archive.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
dismissButton: .default(Text("Continue")) { dismissButton: .default(Text("Continue")) {
showShareSheet(items: [archivePath]) showShareSheet(items: [archivePath])
} }
@@ -439,7 +439,7 @@ struct DatabaseView: View {
private func startChat() { private func startChat() {
if m.chatDbChanged { if m.chatDbChanged {
dismissSettingsSheet() showSettings = false
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
resetChatCtrl() resetChatCtrl()
do { do {
@@ -491,7 +491,7 @@ struct DatabaseView: View {
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory()) appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
do { do {
let chats = try apiGetChats() let chats = try apiGetChats()
m.updateChats(chats) m.updateChats(with: chats)
} catch let error { } catch let error {
logger.error("apiGetChats: cannot update chats \(responseError(error))") logger.error("apiGetChats: cannot update chats \(responseError(error))")
} }
@@ -533,9 +533,7 @@ func deleteChatAsync() async throws {
} }
struct DatabaseView_Previews: PreviewProvider { struct DatabaseView_Previews: PreviewProvider {
@Environment(\.dismiss) static var mockDismiss
static var previews: some View { static var previews: some View {
DatabaseView(dismissSettingsSheet: mockDismiss, chatItemTTL: .none) DatabaseView(showSettings: Binding.constant(false), chatItemTTL: .none)
} }
} }
+5 -7
View File
@@ -11,12 +11,6 @@ import SwiftUI
class AppSheetState: ObservableObject { class AppSheetState: ObservableObject {
static let shared = AppSheetState() static let shared = AppSheetState()
@Published var scenePhaseActive: Bool = false @Published var scenePhaseActive: Bool = false
func redactionReasons(_ protectScreen: Bool) -> RedactionReasons {
!protectScreen || scenePhaseActive
? RedactionReasons()
: RedactionReasons.placeholder
}
} }
private struct PrivacySensitive: ViewModifier { private struct PrivacySensitive: ViewModifier {
@@ -25,7 +19,11 @@ private struct PrivacySensitive: ViewModifier {
@ObservedObject var appSheetState: AppSheetState = AppSheetState.shared @ObservedObject var appSheetState: AppSheetState = AppSheetState.shared
func body(content: Content) -> some View { func body(content: Content) -> some View {
content.redacted(reason: appSheetState.redactionReasons(protectScreen)) if !protectScreen {
content
} else {
content.privacySensitive(!appSheetState.scenePhaseActive).redacted(reason: .privacy)
}
} }
} }
@@ -14,55 +14,50 @@ import SimpleXChat
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically) /// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius /// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier { struct ChatItemClipped: ViewModifier {
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness struct ClipShape: Shape {
@AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var tailEnabled = true let maxCornerRadius: Double
private let chatItem: (content: CIContent, chatDir: CIDirection)?
private let tailVisible: Bool func path(in rect: CGRect) -> Path {
Path(
roundedRect: rect,
cornerRadius: min((rect.height / 2), maxCornerRadius),
style: .circular
)
}
}
init() { init() {
self.chatItem = nil clipShape = ClipShape(
self.tailVisible = false maxCornerRadius: 18
)
} }
init(_ ci: ChatItem, tailVisible: Bool) { init(_ chatItem: ChatItem) {
self.chatItem = (ci.content, ci.chatDir) clipShape = ClipShape(
self.tailVisible = tailVisible maxCornerRadius: {
} switch chatItem.content {
private func shapeStyle() -> ChatItemShape.Style {
if let ci = chatItem {
switch ci.content {
case case
.sndMsgContent, .sndMsgContent,
.rcvMsgContent, .rcvMsgContent,
.rcvDecryptionError, .rcvDecryptionError,
.rcvGroupInvitation,
.sndGroupInvitation,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError, .rcvIntegrityError,
.invalidJSON: .sndModerated,
let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty { .rcvModerated,
false .rcvBlocked,
} else { .invalidJSON: 18
tailVisible default: 8
} }
return tailEnabled }()
? .bubble(
padding: ci.chatDir.sent ? .trailing : .leading,
tailVisible: tail
) )
: .roundRect(radius: msgRectMaxRadius)
case .rcvGroupInvitation, .sndGroupInvitation:
return .roundRect(radius: msgRectMaxRadius)
default: return .roundRect(radius: 8)
}
} else {
return .roundRect(radius: msgRectMaxRadius)
}
} }
private let clipShape: ClipShape
func body(content: Content) -> some View { func body(content: Content) -> some View {
let clipShape = ChatItemShape(
roundness: roundness,
style: shapeStyle()
)
content content
.contentShape(.dragPreview, clipShape) .contentShape(.dragPreview, clipShape)
.contentShape(.contextMenuPreview, clipShape) .contentShape(.contextMenuPreview, clipShape)
@@ -70,106 +65,4 @@ struct ChatItemClipped: ViewModifier {
} }
} }
struct ChatTailPadding: ViewModifier {
func body(content: Content) -> some View {
content.padding(.horizontal, -msgTailWidth)
}
}
private let msgRectMaxRadius: Double = 18
private let msgBubbleMaxRadius: Double = msgRectMaxRadius * 1.2
private let msgTailWidth: Double = 9
private let msgTailMinHeight: Double = msgTailWidth * 1.254 // ~56deg
private let msgTailMaxHeight: Double = msgTailWidth * 1.732 // 60deg
struct ChatItemShape: Shape {
fileprivate enum Style {
case bubble(padding: HorizontalEdge, tailVisible: Bool)
case roundRect(radius: Double)
}
fileprivate let roundness: Double
fileprivate let style: Style
func path(in rect: CGRect) -> Path {
switch style {
case let .bubble(padding, tailVisible):
let w = rect.width
let h = rect.height
let rxMax = min(msgBubbleMaxRadius, w / 2)
let ryMax = min(msgBubbleMaxRadius, h / 2)
let rx = roundness * rxMax
let ry = roundness * ryMax
let tailHeight = min(msgTailMinHeight + roundness * (msgTailMaxHeight - msgTailMinHeight), h / 2)
var path = Path()
// top side
path.move(to: CGPoint(x: rx, y: 0))
path.addLine(to: CGPoint(x: w - rx, y: 0))
if roundness > 0 {
// top-right corner
path.addQuadCurve(to: CGPoint(x: w, y: ry), control: CGPoint(x: w, y: 0))
}
if rect.height > 2 * ry {
// right side
path.addLine(to: CGPoint(x: w, y: h - ry))
}
if roundness > 0 {
// bottom-right corner
path.addQuadCurve(to: CGPoint(x: w - rx, y: h), control: CGPoint(x: w, y: h))
}
// bottom side
if tailVisible {
path.addLine(to: CGPoint(x: -msgTailWidth, y: h))
if roundness > 0 {
// bottom-left tail
// distance of control point from touch point, calculated via ratios
let d = tailHeight - msgTailWidth * msgTailWidth / tailHeight
// tail control point
let tc = CGPoint(x: 0, y: h - tailHeight + d * sqrt(roundness))
// bottom-left tail curve
path.addQuadCurve(to: CGPoint(x: 0, y: h - tailHeight), control: tc)
} else {
path.addLine(to: CGPoint(x: 0, y: h - tailHeight))
}
if rect.height > ry + tailHeight {
// left side
path.addLine(to: CGPoint(x: 0, y: ry))
}
} else {
path.addLine(to: CGPoint(x: rx, y: h))
path.addQuadCurve(to: CGPoint(x: 0, y: h - ry), control: CGPoint(x: 0 , y: h))
if rect.height > 2 * ry {
// left side
path.addLine(to: CGPoint(x: 0, y: ry))
}
}
if roundness > 0 {
// top-left corner
path.addQuadCurve(to: CGPoint(x: rx, y: 0), control: CGPoint(x: 0, y: 0))
}
path.closeSubpath()
return switch padding {
case .leading: path
case .trailing: path
.scale(x: -1, y: 1, anchor: .center)
.path(in: rect)
}
case let .roundRect(radius):
return Path(roundedRect: rect, cornerRadius: radius * roundness)
}
}
var offset: Double? {
switch style {
case let .bubble(padding, isTailVisible):
if isTailVisible {
switch padding {
case .leading: -msgTailWidth
case .trailing: msgTailWidth
}
} else { 0 }
case .roundRect: 0
}
}
}
@@ -11,22 +11,13 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ChatViewBackground: ViewModifier { struct ChatViewBackground: ViewModifier {
@Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme
var image: Image var image: Image
var imageType: WallpaperType var imageType: WallpaperType
var background: Color var background: Color
var tint: Color var tint: Color
func body(content: Content) -> some View { func body(content: Content) -> some View {
// Workaround a problem (SwiftUI bug?) when wallpaper is not updated when user changes global theme in iOS settings from dark to light and vice versa
if colorScheme == .light {
back(content)
} else {
back(content)
}
}
func back(_ content: Content) -> some View {
content.background( content.background(
Canvas { context, size in Canvas { context, size in
var image = context.resolve(image) var image = context.resolve(image)
@@ -100,7 +91,7 @@ extension PresetWallpaper {
scale scale
} else if let type = ChatModel.shared.currentUser?.uiThemes?.preferredMode(base.mode == DefaultThemeMode.dark)?.wallpaper?.toAppWallpaper().type, type.sameType(WallpaperType.preset(filename, nil)) { } else if let type = ChatModel.shared.currentUser?.uiThemes?.preferredMode(base.mode == DefaultThemeMode.dark)?.wallpaper?.toAppWallpaper().type, type.sameType(WallpaperType.preset(filename, nil)) {
type.scale type.scale
} else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename && $0.base == base })?.wallpaper?.scale { } else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename })?.wallpaper?.scale {
scale scale
} else { } else {
Float(1.0) Float(1.0)
@@ -1,21 +0,0 @@
//
// Test.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 31/08/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
extension View {
@ViewBuilder
func invertedForegroundStyle(enabled: Bool = true) -> some View {
if enabled {
foregroundStyle(Material.ultraThin)
.environment(\.colorScheme, .dark)
.grayscale(1)
.contrast(-20)
} else { self }
}
}
@@ -0,0 +1,21 @@
//
// KeyboardPadding.swift
// SimpleX (iOS)
//
// Created by Evgeny on 10/07/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import SwiftUI
extension View {
@ViewBuilder func keyboardPadding() -> some View {
if #available(iOS 17.0, *) {
GeometryReader { g in
self.padding(.bottom, max(0, ChatModel.shared.keyboardHeight - g.safeAreaInsets.bottom))
}
} else {
self
}
}
}
@@ -7,17 +7,16 @@
// //
import SwiftUI import SwiftUI
import SimpleXChat
struct NavLinkPlain<Label: View>: View { struct NavLinkPlain<V: Hashable, Label: View>: View {
let chatId: ChatId @State var tag: V
@Binding var selection: ChatId? @Binding var selection: V?
@ViewBuilder var label: () -> Label @ViewBuilder var label: () -> Label
var disabled = false var disabled = false
var body: some View { var body: some View {
ZStack { ZStack {
Button("") { ItemsModel.shared.loadOpenChat(chatId) } Button("") { DispatchQueue.main.async { selection = tag } }
.disabled(disabled) .disabled(disabled)
label() label()
} }
@@ -20,7 +20,7 @@ struct ProfileImage: View {
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
var body: some View { var body: some View {
if let uiImage = imageFromBase64(imageStr) { if let uiImage = UIImage(base64Encoded: imageStr) {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred) clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred)
} else { } else {
let c = color.asAnotherColorFromSecondaryVariant(theme) let c = color.asAnotherColorFromSecondaryVariant(theme)
+5 -53
View File
@@ -8,63 +8,15 @@
import SwiftUI import SwiftUI
func getTopViewController() -> UIViewController? { func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first, if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
let rootViewController = keyWindow.rootViewController { let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController {
// Find the top-most presented view controller
var topController = rootViewController
while let presentedViewController = topController.presentedViewController {
topController = presentedViewController
}
return topController
}
return nil
}
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
if let topController = getTopViewController() {
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
if let completed = completed { if let completed = completed {
activityViewController.completionWithItemsHandler = { _, _, _, _ in let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() }
completed() activityViewController.completionWithItemsHandler = handler
} }
} presentedViewController.present(activityViewController, animated: true)
topController.present(activityViewController, animated: true)
} }
} }
func showAlert(
title: String,
message: String? = nil,
buttonTitle: String,
buttonAction: @escaping () -> Void,
cancelButton: Bool
) -> Void {
if let topController = getTopViewController() {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: buttonTitle, style: .default) { _ in
buttonAction()
})
if cancelButton {
alert.addAction(cancelAlertAction)
}
topController.present(alert, animated: true)
}
}
func showAlert(
_ title: String,
message: String? = nil,
actions: () -> [UIAlertAction] = { [okAlertAction] }
) {
if let topController = getTopViewController() {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
for action in actions() { alert.addAction(action) }
topController.present(alert, animated: true)
}
}
let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default)
let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)
@@ -1,188 +0,0 @@
//
// SwiftUISheet.swift
// SimpleX (iOS)
//
// Created by user on 23/09/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
private let sheetAnimationDuration: Double = 0.35
// Refrence: https://easings.net/
private let easeOutCubic = UICubicTimingParameters(
controlPoint1: CGPoint(x: 0.215, y: 0.61),
controlPoint2: CGPoint(x: 0.355, y: 1)
)
struct Sheet<SheetContent: View>: ViewModifier {
@Binding var isPresented: Bool
@ViewBuilder let sheetContent: () -> SheetContent
func body(content: Content) -> some View {
ZStack {
content
SheetRepresentable(isPresented: $isPresented, content: sheetContent())
.allowsHitTesting(isPresented)
.ignoresSafeArea()
}
}
}
struct SheetRepresentable<Content: View>: UIViewControllerRepresentable {
@Binding var isPresented: Bool
let content: Content
func makeUIViewController(context: Context) -> Controller<Content> {
Controller(content: content, representer: self)
}
func updateUIViewController(_ sheetController: Controller<Content>, context: Context) {
sheetController.animate(isPresented: isPresented)
}
class Controller<C: View>: UIViewController {
let hostingController: UIHostingController<C>
private let animator = UIViewPropertyAnimator(
duration: sheetAnimationDuration,
timingParameters: easeOutCubic
)
private let representer: SheetRepresentable<C>
private var retainedFraction: CGFloat = 0
private var sheetHeight: Double { hostingController.view.frame.height }
private var task: Task<Void, Never>?
init(content: C, representer: SheetRepresentable<C>) {
self.representer = representer
self.hostingController = UIHostingController(rootView: content)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) missing") }
deinit {
animator.stopAnimation(true)
animator.finishAnimation(at: .current)
}
func animate(isPresented: Bool) {
let alreadyAnimating = animator.isRunning && isPresented != animator.isReversed
let sheetFullyDismissed = animator.fractionComplete == (animator.isReversed ? 1 : 0)
let sheetFullyPresented = animator.fractionComplete == (animator.isReversed ? 0 : 1)
if !isPresented && sheetFullyDismissed ||
isPresented && sheetFullyPresented ||
alreadyAnimating {
return
}
animator.pauseAnimation()
animator.isReversed = !isPresented
animator.continueAnimation(
withTimingParameters: isPresented
? easeOutCubic
: UICubicTimingParameters(animationCurve: .easeIn),
durationFactor: 1 - animator.fractionComplete
)
handleVisibility()
}
func handleVisibility() {
if animator.isReversed {
task = Task {
do {
let sleepDuration = UInt64(sheetAnimationDuration * Double(NSEC_PER_SEC))
try await Task.sleep(nanoseconds: sleepDuration)
view.isHidden = true
} catch { }
}
} else {
task?.cancel()
task = nil
view.isHidden = false
}
}
override func viewDidLoad() {
view.isHidden = true
view.backgroundColor = .clear
view.addGestureRecognizer(
UITapGestureRecognizer(target: self, action: #selector(tap(gesture:)))
)
addChild(hostingController)
hostingController.didMove(toParent: self)
if let sheet = hostingController.view {
sheet.isHidden = true
sheet.clipsToBounds = true
sheet.layer.cornerRadius = 10
sheet.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
sheet.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(pan(gesture:))))
sheet.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(sheet)
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
}
override func viewDidAppear(_ animated: Bool) {
// Ensures animations are only setup once
// on some iOS version `viewDidAppear` can get called on each state change.
if hostingController.view.isHidden {
hostingController.view.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight)
hostingController.view.isHidden = false
animator.pausesOnCompletion = true
animator.addAnimations {
self.hostingController.view.transform = .identity
self.view.backgroundColor = UIColor {
switch $0.userInterfaceStyle {
case .dark: .black.withAlphaComponent(0.290)
default: .black.withAlphaComponent(0.121)
}
}
}
animator.startAnimation()
animator.pauseAnimation()
}
}
@objc
func pan(gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .began:
animator.isReversed = false
animator.pauseAnimation()
retainedFraction = animator.fractionComplete
case .changed:
animator.fractionComplete = retainedFraction - gesture.translation(in: view).y / sheetHeight
case .ended, .cancelled:
let velocity = gesture.velocity(in: view).y
animator.isReversed = (velocity - (animator.fractionComplete - 0.5) * 100).sign == .plus
let defaultVelocity = sheetHeight / sheetAnimationDuration
let fractionRemaining = 1 - animator.fractionComplete
let durationFactor = min(max(fractionRemaining / (abs(velocity) / defaultVelocity), 0.5), 1)
animator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor * fractionRemaining)
handleVisibility()
DispatchQueue.main.asyncAfter(deadline: .now() + sheetAnimationDuration) {
self.representer.isPresented = !self.animator.isReversed
}
default: break
}
}
@objc
func tap(gesture: UITapGestureRecognizer) {
switch gesture.state {
case .ended:
if gesture.location(in: view).y < view.frame.height - sheetHeight {
representer.isPresented = false
}
default: break
}
}
}
}
@@ -1,61 +0,0 @@
//
// StickyScrollView.swift
// SimpleX (iOS)
//
// Created by user on 20/09/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct StickyScrollView<Content: View>: UIViewRepresentable {
@Binding var resetScroll: ResetScrollAction
@ViewBuilder let content: () -> Content
func makeUIView(context: Context) -> UIScrollView {
let hc = context.coordinator.hostingController
hc.view.backgroundColor = .clear
let sv = UIScrollView()
sv.showsHorizontalScrollIndicator = false
sv.addSubview(hc.view)
sv.delegate = context.coordinator
DispatchQueue.main.async {
resetScroll = ResetScrollAction { sv.setContentOffset(.zero, animated: false) }
}
return sv
}
func updateUIView(_ scrollView: UIScrollView, context: Context) {
let hc = context.coordinator.hostingController
hc.rootView = content()
hc.view.frame.size = hc.view.intrinsicContentSize
scrollView.contentSize = hc.view.intrinsicContentSize
}
func makeCoordinator() -> Coordinator {
Coordinator(content: content())
}
class Coordinator: NSObject, UIScrollViewDelegate {
let hostingController: UIHostingController<Content>
init(content: Content) {
self.hostingController = UIHostingController(rootView: content)
}
func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>
) {
if targetContentOffset.pointee.x < 32 {
targetContentOffset.pointee.x = 0
}
}
}
}
struct ResetScrollAction {
var action = { }
func callAsFunction() { action() }
}
@@ -16,7 +16,6 @@ struct UserWallpaperEditor: View {
@State var themeModeOverride: ThemeModeOverride @State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode? @State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false @State var showMore: Bool = false
@State var showFileImporter: Bool = false
@Binding var globalThemeUsed: Bool @Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@@ -126,13 +125,7 @@ struct UserWallpaperEditor: View {
CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) }) CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) })
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) ImportExportThemeSection(perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
} else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
.modifier(
ThemeImporter(isPresented: $showFileImporter) { imported in
let importedFromString = imported.wallpaper?.importFromString() let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get()) let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
@@ -146,7 +139,10 @@ struct UserWallpaperEditor: View {
await save(applyToMode, res) await save(applyToMode, res)
} }
} }
) } else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
} }
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool { private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
@@ -220,7 +216,6 @@ struct ChatWallpaperEditor: View {
@State var themeModeOverride: ThemeModeOverride @State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode? @State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false @State var showMore: Bool = false
@State var showFileImporter: Bool = false
@Binding var globalThemeUsed: Bool @Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@@ -333,13 +328,7 @@ struct ChatWallpaperEditor: View {
CustomizeThemeColorsSection(editColor: editColor) CustomizeThemeColorsSection(editColor: editColor)
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) ImportExportThemeSection(perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
} else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
.modifier(
ThemeImporter(isPresented: $showFileImporter) { imported in
let importedFromString = imported.wallpaper?.importFromString() let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get()) let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
@@ -353,7 +342,10 @@ struct ChatWallpaperEditor: View {
await save(applyToMode, res) await save(applyToMode, res)
} }
} }
) } else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
} }
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool { private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
@@ -1,62 +0,0 @@
//
// UserDefault.swift
// SimpleX (iOS)
//
// Created by user on 14/10/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import Combine
@propertyWrapper
public struct UserDefault<Value: Equatable>: DynamicProperty {
@StateObject private var observer = UserDefaultObserver()
let initialValue: Value
let key: String
let store: UserDefaults
public init(
wrappedValue: Value,
_ key: String,
store: UserDefaults = .standard
) {
self.initialValue = wrappedValue
self.key = key
self.store = store
}
public var wrappedValue: Value {
get {
// Observer can only be accessed after the property wrapper is installed in view (runtime exception)
observer.subscribe(to: key)
return store.object(forKey: key) as? Value ?? initialValue
}
nonmutating set {
store.set(newValue, forKey: key)
}
}
}
private class UserDefaultObserver: ObservableObject {
private var subscribed = false
func subscribe(to key: String) {
if !subscribed {
NotificationCenter.default.addObserver(
self,
selector: #selector(userDefaultsDidChange),
name: UserDefaults.didChangeNotification,
object: nil
)
subscribed = true
}
}
@objc
private func userDefaultsDidChange(_ notification: Notification) {
Task { @MainActor in objectWillChange.send() }
}
deinit { NotificationCenter.default.removeObserver(self) }
}
@@ -65,7 +65,8 @@ struct LocalAuthView: View {
// Clear sensitive data on screen just in case app fails to hide its views while new database is created // Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil m.chatId = nil
ItemsModel.shared.reversedChatItems = [] ItemsModel.shared.reversedChatItems = []
m.updateChats([]) m.chats = []
m.popChatCollector.clear()
m.users = [] m.users = []
_ = kcAppPassword.set(password) _ = kcAppPassword.set(password)
_ = kcSelfDestructPassword.remove() _ = kcSelfDestructPassword.remove()
@@ -24,7 +24,6 @@ private enum MigrationFromState: Equatable {
} }
private enum MigrateFromDeviceViewAlert: Identifiable { private enum MigrateFromDeviceViewAlert: Identifiable {
case finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl)
case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.") case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.")
case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures") case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures")
@@ -39,7 +38,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
var id: String { var id: String {
switch self { switch self {
case .finishMigration: return "finishMigration"
case let .deleteChat(title, text): return "\(title) \(text)" case let .deleteChat(title, text): return "\(title) \(text)"
case let .startChat(title, text): return "\(title) \(text)" case let .startChat(title, text): return "\(title) \(text)"
@@ -58,6 +56,8 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
struct MigrateFromDevice: View { struct MigrateFromDevice: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var showSettings: Bool
@Binding var showProgressOnSettings: Bool @Binding var showProgressOnSettings: Bool
@State private var migrationState: MigrationFromState = .chatStopInProgress @State private var migrationState: MigrationFromState = .chatStopInProgress
@State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var useKeychain = storeDBPassphraseGroupDefault.get()
@@ -106,6 +106,9 @@ struct MigrateFromDevice: View {
finishedView(chatDeletion) finishedView(chatDeletion)
} }
} }
.modifier(BackButton(label: "Back", disabled: $backDisabled) {
dismiss()
})
.onChange(of: migrationState) { state in .onChange(of: migrationState) { state in
backDisabled = switch migrationState { backDisabled = switch migrationState {
case .chatStopInProgress, .archiving, .linkShown, .finished: true case .chatStopInProgress, .archiving, .linkShown, .finished: true
@@ -135,15 +138,6 @@ struct MigrateFromDevice: View {
} }
.alert(item: $alert) { alert in .alert(item: $alert) { alert in
switch alert { switch alert {
case let .finishMigration(fileId, ctrl):
return Alert(
title: Text("Remove archive?"),
message: Text("The uploaded database archive will be permanently removed from the servers."),
primaryButton: .destructive(Text("Continue")) {
finishMigration(fileId, ctrl)
},
secondaryButton: .cancel()
)
case let .startChat(title, text): case let .startChat(title, text):
return Alert( return Alert(
title: Text(title), title: Text(title),
@@ -177,7 +171,7 @@ struct MigrateFromDevice: View {
case let .archiveExportedWithErrors(archivePath, errs): case let .archiveExportedWithErrors(archivePath, errs):
return Alert( return Alert(
title: Text("Chat database exported"), title: Text("Chat database exported"),
message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs), message: Text("You may migrate the exported database.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
dismissButton: .default(Text("Continue")) { dismissButton: .default(Text("Continue")) {
Task { await uploadArchive(path: archivePath) } Task { await uploadArchive(path: archivePath) }
} }
@@ -324,7 +318,7 @@ struct MigrateFromDevice: View {
Text("Cancel migration").foregroundColor(.red) Text("Cancel migration").foregroundColor(.red)
} }
} }
Button(action: { alert = .finishMigration(fileId, ctrl) }) { Button(action: { finishMigration(fileId, ctrl) }) {
settingsRow("checkmark", color: theme.colors.secondary) { settingsRow("checkmark", color: theme.colors.secondary) {
Text("Finalize migration").foregroundColor(theme.colors.primary) Text("Finalize migration").foregroundColor(theme.colors.primary)
} }
@@ -529,15 +523,9 @@ struct MigrateFromDevice: View {
} }
case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs): case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs):
let cfg = getNetCfg() let cfg = getNetCfg()
let proxy: NetworkProxy? = if cfg.socksProxy == nil {
nil
} else {
networkProxyDefault.get()
}
let data = MigrationFileLinkData.init( let data = MigrationFileLinkData.init(
networkConfig: MigrationFileLinkData.NetworkConfig( networkConfig: MigrationFileLinkData.NetworkConfig(
socksProxy: cfg.socksProxy, socksProxy: cfg.socksProxy,
networkProxy: proxy,
hostMode: cfg.hostMode, hostMode: cfg.hostMode,
requiredHostMode: cfg.requiredHostMode requiredHostMode: cfg.requiredHostMode
) )
@@ -602,7 +590,7 @@ struct MigrateFromDevice: View {
} catch let error { } catch let error {
fatalError("Error starting chat \(responseError(error))") fatalError("Error starting chat \(responseError(error))")
} }
dismissAllSheets(animated: true) showSettings = false
} }
} catch let error { } catch let error {
alert = .error(title: "Error deleting database", error: responseError(error)) alert = .error(title: "Error deleting database", error: responseError(error))
@@ -625,7 +613,9 @@ struct MigrateFromDevice: View {
} }
// Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered // Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered
if dismiss || m.chatDbStatus != .ok { if dismiss || m.chatDbStatus != .ok {
dismissAllSheets(animated: true) await MainActor.run {
showSettings = false
}
} }
} }
@@ -777,6 +767,6 @@ private class MigrationChatReceiver {
struct MigrateFromDevice_Previews: PreviewProvider { struct MigrateFromDevice_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
MigrateFromDevice(showProgressOnSettings: Binding.constant(false)) MigrateFromDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false))
} }
} }
@@ -93,6 +93,7 @@ struct MigrateToDevice: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@Binding var migrationState: MigrationToState? @Binding var migrationState: MigrationToState?
@State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var alert: MigrateToDeviceViewAlert? @State private var alert: MigrateToDeviceViewAlert?
@@ -101,7 +102,6 @@ struct MigrateToDevice: View {
// Prevent from hiding the view until migration is finished or app deleted // Prevent from hiding the view until migration is finished or app deleted
@State private var backDisabled: Bool = false @State private var backDisabled: Bool = false
@State private var showQRCodeScanner: Bool = true @State private var showQRCodeScanner: Bool = true
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
var body: some View { var body: some View {
VStack { VStack {
@@ -197,12 +197,14 @@ struct MigrateToDevice: View {
} }
} }
} }
if developerTools {
Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) { Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
pasteLinkView() pasteLinkView()
} }
} }
} }
} }
}
private func pasteLinkView() -> some View { private func pasteLinkView() -> some View {
Button { Button {
@@ -216,7 +218,7 @@ struct MigrateToDevice: View {
} label: { } label: {
Text("Tap to paste link") Text("Tap to paste link")
} }
.disabled(!pasteboardHasStrings) .disabled(!ChatModel.shared.pasteboardHasStrings)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
@@ -485,9 +487,6 @@ struct MigrateToDevice: View {
do { do {
if !hasChatCtrl() { if !hasChatCtrl() {
chatInitControllerRemovingDatabases() chatInitControllerRemovingDatabases()
} else if ChatModel.shared.chatRunning == true {
// cannot delete storage if chat is running
try await apiStopChat()
} }
try await apiDeleteStorage() try await apiDeleteStorage()
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
@@ -557,23 +556,12 @@ struct MigrateToDevice: View {
do { do {
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
MigrationToDeviceState.save(nil) MigrationToDeviceState.save(nil)
try ObjC.catchException {
appSettings.importIntoApp() appSettings.importIntoApp()
}
do {
try SimpleX.startChat(refreshInvitations: true) try SimpleX.startChat(refreshInvitations: true)
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.") AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
} catch let error { } catch let error {
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error)))) AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
} }
} catch let error {
logger.error("Error importing settings: \(error.localizedDescription)")
AlertManager.shared.showAlert(
Alert(
title: Text("Error migrating settings"),
message: Text ("Some app settings were not migrated.") + Text("\n") + Text(responseError(error)))
)
}
hideView() hideView()
} }
@@ -28,9 +28,7 @@ struct AddContactLearnMore: View {
Text("If you can't meet in person, show QR code in a video call, or share the link.") Text("If you can't meet in person, show QR code in a video call, or share the link.")
Text("Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).") Text("Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).")
} }
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
} }
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} }
@@ -37,7 +37,7 @@ struct AddGroupView: View {
) { _ in ) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(groupInfo.id) m.chatId = groupInfo.id
} }
} }
} }
@@ -52,14 +52,14 @@ struct AddGroupView: View {
) { ) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(groupInfo.id) m.chatId = groupInfo.id
} }
} }
} }
.navigationBarTitle("Group link") .navigationBarTitle("Group link")
} }
} else { } else {
createGroupView() createGroupView().keyboardPadding()
} }
} }
@@ -137,13 +137,10 @@ struct AddGroupView: View {
createInvalidNameAlert(mkValidName(profile.displayName), $profile.displayName) createInvalidNameAlert(mkValidName(profile.displayName), $profile.displayName)
} }
.onChange(of: chosenImage) { image in .onChange(of: chosenImage) { image in
Task { if let image = image {
let resized: String? = if let image { profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
} else { } else {
nil profile.image = nil
}
await MainActor.run { profile.image = resized }
} }
} }
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
@@ -14,11 +14,9 @@ enum ContactType: Int {
} }
struct NewChatMenuButton: View { struct NewChatMenuButton: View {
// do not use chatModel here because it prevents showing AddGroupMembersView after group creation and QR code after link creation on iOS 16
// @EnvironmentObject var chatModel: ChatModel
@State private var showNewChatSheet = false @State private var showNewChatSheet = false
@State private var alert: SomeAlert? = nil @State private var alert: SomeAlert? = nil
@State private var pendingConnection: PendingContactConnection? = nil @State private var globalAlert: SomeAlert? = nil
var body: some View { var body: some View {
Button { Button {
@@ -30,17 +28,25 @@ struct NewChatMenuButton: View {
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
} }
.appSheet(isPresented: $showNewChatSheet) { .appSheet(isPresented: $showNewChatSheet) {
NewChatSheet(pendingConnection: $pendingConnection) NewChatSheet(alert: $alert)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) .environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
.onDisappear {
alert = cleanupPendingConnection(contactConnection: pendingConnection)
pendingConnection = nil
}
}
.alert(item: $alert) { a in .alert(item: $alert) { a in
return a.alert return a.alert
} }
} }
// This is a workaround to show "Keep unused invitation" alert in both following cases:
// - on going back from NewChatView to NewChatSheet,
// - on dismissing NewChatMenuButton sheet while on NewChatView (skipping NewChatSheet)
.onChange(of: alert?.id) { a in
if !showNewChatSheet && alert != nil {
globalAlert = alert
alert = nil
}
}
.alert(item: $globalAlert) { a in
return a.alert
}
}
} }
private var indent: CGFloat = 36 private var indent: CGFloat = 36
@@ -54,8 +60,7 @@ struct NewChatSheet: View {
@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 alert: SomeAlert? @Binding var alert: SomeAlert?
@Binding var pendingConnection: PendingContactConnection?
// Sheet height management // Sheet height management
@State private var isAddContactActive = false @State private var isAddContactActive = false
@@ -73,9 +78,6 @@ struct NewChatSheet: View {
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.navigationBarHidden(searchMode) .navigationBarHidden(searchMode)
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
.alert(item: $alert) { a in
return a.alert
}
} }
if #available(iOS 16.0, *), oneHandUI { if #available(iOS 16.0, *), oneHandUI {
let sheetHeight: CGFloat = showArchive ? 575 : 500 let sheetHeight: CGFloat = showArchive ? 575 : 500
@@ -110,7 +112,7 @@ struct NewChatSheet: View {
if (searchText.isEmpty) { if (searchText.isEmpty) {
Section { Section {
NavigationLink(isActive: $isAddContactActive) { NavigationLink(isActive: $isAddContactActive) {
NewChatView(selection: .invite, parentAlert: $alert, contactConnection: $pendingConnection) NewChatView(selection: .invite, parentAlert: $alert)
.navigationTitle("New chat") .navigationTitle("New chat")
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
@@ -120,7 +122,7 @@ struct NewChatSheet: View {
} }
} }
NavigationLink(isActive: $isScanPasteLinkActive) { NavigationLink(isActive: $isScanPasteLinkActive) {
NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert, contactConnection: $pendingConnection) NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert)
.navigationTitle("New chat") .navigationTitle("New chat")
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
@@ -197,7 +199,7 @@ func chatContactType(chat: Chat) -> ContactType {
case .contactRequest: case .contactRequest:
return .request return .request
case let .direct(contact): case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { if contact.activeConn == nil && contact.profile.contactLink != nil {
return .card return .card
} else if contact.chatDeleted { } else if contact.chatDeleted {
return .chatDeleted return .chatDeleted
+47 -349
View File
@@ -45,47 +45,18 @@ enum NewChatOption: Identifiable {
var id: Self { self } var id: Self { self }
} }
func cleanupPendingConnection(contactConnection: PendingContactConnection?) -> SomeAlert? {
var alert: SomeAlert? = nil
if !(ChatModel.shared.showingInvitation?.connChatUsed ?? true),
let conn = contactConnection {
alert = SomeAlert(
alert: Alert(
title: Text("Keep unused invitation?"),
message: Text("You can view invitation link again in connection details."),
primaryButton: .default(Text("Keep")) {},
secondaryButton: .destructive(Text("Delete")) {
Task {
await deleteChat(Chat(
chatInfo: .contactConnection(contactConnection: conn),
chatItems: []
))
}
}
),
id: "keepUnusedInvitation"
)
}
ChatModel.shared.showingInvitation = nil
return alert
}
struct NewChatView: View { struct NewChatView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@State var selection: NewChatOption @State var selection: NewChatOption
@State var showQRCodeScanner = false @State var showQRCodeScanner = false
@State private var invitationUsed: Bool = false @State private var invitationUsed: Bool = false
@State private var contactConnection: PendingContactConnection? = nil
@State private var connReqInvitation: String = "" @State private var connReqInvitation: String = ""
@State private var creatingConnReq = false @State private var creatingConnReq = false
@State var choosingProfile = false
@State private var pastedLink: String = "" @State private var pastedLink: String = ""
@State private var alert: NewChatViewAlert? @State private var alert: NewChatViewAlert?
@Binding var parentAlert: SomeAlert? @Binding var parentAlert: SomeAlert?
@Binding var contactConnection: PendingContactConnection?
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -97,11 +68,6 @@ struct NewChatView: View {
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.padding() .padding()
.onChange(of: $selection.wrappedValue) { opt in
if opt == NewChatOption.connect {
showQRCodeScanner = true
}
}
VStack { VStack {
// it seems there's a bug in iOS 15 if several views in switch (or if-else) statement have different transitions // it seems there's a bug in iOS 15 if several views in switch (or if-else) statement have different transitions
@@ -156,11 +122,27 @@ struct NewChatView: View {
} }
} }
.onDisappear { .onDisappear {
if !choosingProfile { if !(m.showingInvitation?.connChatUsed ?? true),
parentAlert = cleanupPendingConnection(contactConnection: contactConnection) let conn = contactConnection {
contactConnection = nil parentAlert = SomeAlert(
alert: Alert(
title: Text("Keep unused invitation?"),
message: Text("You can view invitation link again in connection details."),
primaryButton: .default(Text("Keep")) {},
secondaryButton: .destructive(Text("Delete")) {
Task {
await deleteChat(Chat(
chatInfo: .contactConnection(contactConnection: conn),
chatItems: []
))
} }
} }
),
id: "keepUnusedInvitation"
)
}
m.showingInvitation = nil
}
.alert(item: $alert) { a in .alert(item: $alert) { a in
switch(a) { switch(a) {
case let .planAndConnectAlert(alert): case let .planAndConnectAlert(alert):
@@ -177,8 +159,7 @@ struct NewChatView: View {
InviteView( InviteView(
invitationUsed: $invitationUsed, invitationUsed: $invitationUsed,
contactConnection: $contactConnection, contactConnection: $contactConnection,
connReqInvitation: $connReqInvitation, connReqInvitation: connReqInvitation
choosingProfile: $choosingProfile
) )
} else if creatingConnReq { } else if creatingConnReq {
creatingLinkProgressView() creatingLinkProgressView()
@@ -229,22 +210,12 @@ struct NewChatView: View {
} }
} }
private func incognitoProfileImage() -> some View {
Image(systemName: "theatermasks.fill")
.resizable()
.scaledToFit()
.frame(width: 30)
.foregroundColor(.indigo)
}
private struct InviteView: View { private struct InviteView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var invitationUsed: Bool @Binding var invitationUsed: Bool
@Binding var contactConnection: PendingContactConnection? @Binding var contactConnection: PendingContactConnection?
@Binding var connReqInvitation: String var connReqInvitation: String
@Binding var choosingProfile: Bool
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
var body: some View { var body: some View {
@@ -255,40 +226,28 @@ private struct InviteView: View {
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
qrCodeView() qrCodeView()
if let selectedProfile = chatModel.currentUser {
Section { Section {
NavigationLink { IncognitoToggle(incognitoEnabled: $incognitoDefault)
ActiveProfilePicker(
contactConnection: $contactConnection,
connReqInvitation: $connReqInvitation,
incognitoEnabled: $incognitoDefault,
choosingProfile: $choosingProfile,
selectedProfile: selectedProfile
)
} label: {
HStack {
if incognitoDefault {
incognitoProfileImage()
Text("Incognito")
} else {
ProfileImage(imageStr: chatModel.currentUser?.image, size: 30)
Text(chatModel.currentUser?.chatViewName ?? "")
}
}
}
} header: {
Text("Share profile").foregroundColor(theme.colors.secondary)
} footer: { } footer: {
if incognitoDefault { sharedProfileInfo(incognitoDefault)
Text("A new random profile will be shared.") .foregroundColor(theme.colors.secondary)
}
}
} }
} }
.onChange(of: incognitoDefault) { incognito in .onChange(of: incognitoDefault) { incognito in
setInvitationUsed() Task {
do {
if let contactConn = contactConnection,
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run {
contactConnection = conn
chatModel.updateContactConnection(conn)
}
}
} catch {
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
}
} }
.onChange(of: chatModel.currentUser) { u in
setInvitationUsed() setInvitationUsed()
} }
} }
@@ -311,7 +270,6 @@ private struct InviteView: View {
private func qrCodeView() -> some View { private func qrCodeView() -> some View {
Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) { Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) {
SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed) SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed)
.id("simplex-qrcode-view-for-\(connReqInvitation)")
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
@@ -331,257 +289,6 @@ private struct InviteView: View {
} }
} }
private enum ProfileSwitchStatus {
case switchingUser
case switchingIncognito
case idle
}
private struct ActiveProfilePicker: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var contactConnection: PendingContactConnection?
@Binding var connReqInvitation: String
@Binding var incognitoEnabled: Bool
@Binding var choosingProfile: Bool
@State private var alert: SomeAlert?
@State private var profileSwitchStatus: ProfileSwitchStatus = .idle
@State private var switchingProfileByTimeout = false
@State private var lastSwitchingProfileByTimeoutCall: Double?
@State private var profiles: [User] = []
@State private var searchTextOrPassword = ""
@State private var showIncognitoSheet = false
@State private var incognitoFirst: Bool = false
@State var selectedProfile: User
var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
var body: some View {
viewBody()
.navigationTitle("Select chat profile")
.searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always))
.autocorrectionDisabled(true)
.navigationBarTitleDisplayMode(.large)
.onAppear {
profiles = chatModel.users
.map { $0.user }
.sorted { u, _ in u.activeUser }
}
.onChange(of: incognitoEnabled) { incognito in
if profileSwitchStatus != .switchingIncognito {
return
}
Task {
do {
if let contactConn = contactConnection,
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run {
contactConnection = conn
chatModel.updateContactConnection(conn)
profileSwitchStatus = .idle
dismiss()
}
}
} catch {
profileSwitchStatus = .idle
incognitoEnabled = !incognito
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
let err = getErrorAlert(error, "Error changing to incognito!")
alert = SomeAlert(
alert: Alert(
title: Text(err.title),
message: Text(err.message ?? "Error: \(responseError(error))")
),
id: "setConnectionIncognitoError"
)
}
}
}
.onChange(of: profileSwitchStatus) { sp in
if sp != .idle {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
switchingProfileByTimeout = profileSwitchStatus != .idle
}
} else {
switchingProfileByTimeout = false
}
}
.onChange(of: selectedProfile) { profile in
if (profileSwitchStatus != .switchingUser) {
return
}
Task {
do {
if let contactConn = contactConnection,
let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) {
await MainActor.run {
contactConnection = conn
connReqInvitation = conn.connReqInv ?? ""
incognitoEnabled = false
chatModel.updateContactConnection(conn)
}
do {
try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil )
await MainActor.run {
profileSwitchStatus = .idle
dismiss()
}
} catch {
await MainActor.run {
profileSwitchStatus = .idle
alert = SomeAlert(
alert: Alert(
title: Text("Error switching profile"),
message: Text("Your connection was moved to \(profile.chatViewName) but an unexpected error occurred while redirecting you to the profile.")
),
id: "switchingProfileError"
)
}
}
}
} catch {
await MainActor.run {
profileSwitchStatus = .idle
if let currentUser = chatModel.currentUser {
selectedProfile = currentUser
}
let err = getErrorAlert(error, "Error changing connection profile")
alert = SomeAlert(
alert: Alert(
title: Text(err.title),
message: Text(err.message ?? "Error: \(responseError(error))")
),
id: "changeConnectionUserError"
)
}
}
}
}
.alert(item: $alert) { a in
a.alert
}
.onAppear {
incognitoFirst = incognitoEnabled
choosingProfile = true
}
.onDisappear {
choosingProfile = false
}
.sheet(isPresented: $showIncognitoSheet) {
IncognitoHelp()
}
}
@ViewBuilder private func viewBody() -> some View {
profilePicker()
.allowsHitTesting(!switchingProfileByTimeout)
.modifier(ThemedBackground(grouped: true))
.overlay {
if switchingProfileByTimeout {
ProgressView()
.scaleEffect(2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
private func filteredProfiles() -> [User] {
let s = trimmedSearchTextOrPassword
let lower = s.localizedLowercase
return profiles.filter { u in
if (u.activeUser || !u.hidden) && (s == "" || u.chatViewName.localizedLowercase.contains(lower)) {
return true
}
return correctPassword(u, s)
}
}
@ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View {
Button {
if selectedProfile == user && incognitoEnabled {
incognitoEnabled = false
profileSwitchStatus = .switchingIncognito
} else if selectedProfile != user {
selectedProfile = user
profileSwitchStatus = .switchingUser
}
} label: {
HStack {
ProfileImage(imageStr: user.image, size: 30)
.padding(.trailing, 2)
Text(user.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
Spacer()
if selectedProfile == user, !incognitoEnabled {
Image(systemName: "checkmark")
.resizable().scaledToFit().frame(width: 16)
.foregroundColor(theme.colors.primary)
}
}
}
}
@ViewBuilder private func profilePicker() -> some View {
let incognitoOption = Button {
if !incognitoEnabled {
incognitoEnabled = true
profileSwitchStatus = .switchingIncognito
}
} label : {
HStack {
incognitoProfileImage()
Text("Incognito")
.foregroundColor(theme.colors.onBackground)
Image(systemName: "info.circle")
.foregroundColor(theme.colors.primary)
.font(.system(size: 14))
.onTapGesture {
showIncognitoSheet = true
}
Spacer()
if incognitoEnabled {
Image(systemName: "checkmark")
.resizable().scaledToFit().frame(width: 16)
.foregroundColor(theme.colors.primary)
}
}
}
List {
let filteredProfiles = filteredProfiles()
let activeProfile = filteredProfiles.first { u in u.activeUser }
if let selectedProfile = activeProfile {
let otherProfiles = filteredProfiles.filter { u in u.userId != activeProfile?.userId }
if incognitoFirst {
incognitoOption
profilerPickerUserOption(selectedProfile)
} else {
profilerPickerUserOption(selectedProfile)
incognitoOption
}
ForEach(otherProfiles) { p in
profilerPickerUserOption(p)
}
} else {
incognitoOption
ForEach(filteredProfiles) { p in
profilerPickerUserOption(p)
}
}
}
.opacity(switchingProfileByTimeout ? 0.4 : 1)
}
}
private struct ConnectView: View { private struct ConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@@ -589,7 +296,6 @@ private struct ConnectView: View {
@Binding var pastedLink: String @Binding var pastedLink: String
@Binding var alert: NewChatViewAlert? @Binding var alert: NewChatViewAlert?
@State private var sheet: PlanAndConnectActionSheet? @State private var sheet: PlanAndConnectActionSheet?
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
var body: some View { var body: some View {
List { List {
@@ -626,7 +332,7 @@ private struct ConnectView: View {
} label: { } label: {
Text("Tap to paste link") Text("Tap to paste link")
} }
.disabled(!pasteboardHasStrings) .disabled(!ChatModel.shared.pasteboardHasStrings)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} else { } else {
linkTextView(pastedLink) linkTextView(pastedLink)
@@ -697,7 +403,6 @@ struct ScannerInView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(Color.clear) .foregroundColor(Color.clear)
switch cameraAuthorizationStatus { switch cameraAuthorizationStatus {
case .authorized, nil: EmptyView()
case .restricted: Text("Camera not available") case .restricted: Text("Camera not available")
case .denied: Label("Enable camera access", systemImage: "camera") case .denied: Label("Enable camera access", systemImage: "camera")
default: Label("Tap to scan", systemImage: "qrcode") default: Label("Tap to scan", systemImage: "qrcode")
@@ -717,26 +422,21 @@ struct ScannerInView: View {
.disabled(cameraAuthorizationStatus == .restricted) .disabled(cameraAuthorizationStatus == .restricted)
} }
} }
.task { .onAppear {
let status = AVCaptureDevice.authorizationStatus(for: .video) let status = AVCaptureDevice.authorizationStatus(for: .video)
cameraAuthorizationStatus = status cameraAuthorizationStatus = status
if showQRCodeScanner { if showQRCodeScanner {
switch status { switch status {
case .notDetermined: await askCameraAuthorizationAsync() case .notDetermined: askCameraAuthorization()
case .restricted: showQRCodeScanner = false case .restricted: showQRCodeScanner = false
case .denied: showQRCodeScanner = false case .denied: showQRCodeScanner = false
case .authorized: () case .authorized: ()
@unknown default: await askCameraAuthorizationAsync() @unknown default: askCameraAuthorization()
} }
} }
} }
} }
func askCameraAuthorizationAsync() async {
await AVCaptureDevice.requestAccess(for: .video)
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
}
func askCameraAuthorization(_ cb: (() -> Void)? = nil) { func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
AVCaptureDevice.requestAccess(for: .video) { allowed in AVCaptureDevice.requestAccess(for: .video) { allowed in
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
@@ -1198,11 +898,11 @@ func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert:
DispatchQueue.main.async { DispatchQueue.main.async {
if dismiss { if dismiss {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(c.id) m.chatId = c.id
showAlreadyExistsAlert?() showAlreadyExistsAlert?()
} }
} else { } else {
ItemsModel.shared.loadOpenChat(c.id) m.chatId = c.id
showAlreadyExistsAlert?() showAlreadyExistsAlert?()
} }
} }
@@ -1217,11 +917,11 @@ func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAler
DispatchQueue.main.async { DispatchQueue.main.async {
if dismiss { if dismiss {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(g.id) m.chatId = g.id
showAlreadyExistsAlert?() showAlreadyExistsAlert?()
} }
} else { } else {
ItemsModel.shared.loadOpenChat(g.id) m.chatId = g.id
showAlreadyExistsAlert?() showAlreadyExistsAlert?()
} }
} }
@@ -1275,12 +975,10 @@ func connReqSentAlert(_ type: ConnReqType) -> Alert {
struct NewChatView_Previews: PreviewProvider { struct NewChatView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
@State var parentAlert: SomeAlert? @State var parentAlert: SomeAlert?
@State var contactConnection: PendingContactConnection? = nil
NewChatView( NewChatView(
selection: .invite, selection: .invite,
parentAlert: $parentAlert, parentAlert: $parentAlert
contactConnection: $contactConnection
) )
} }
} }
+5 -5
View File
@@ -49,6 +49,7 @@ struct QRCode: View {
ZStack { ZStack {
if let image = image { if let image = image {
qrCodeImage(image) qrCodeImage(image)
}
GeometryReader { geo in GeometryReader { geo in
ZStack { ZStack {
if withLogo { if withLogo {
@@ -71,12 +72,11 @@ struct QRCode: View {
} }
.frame(width: geo.size.width, height: geo.size.height) .frame(width: geo.size.width, height: geo.size.height)
} }
} else {
Color.clear.aspectRatio(1, contentMode: .fit)
}
} }
.onTapGesture(perform: makeScreenshotFunc) .onTapGesture(perform: makeScreenshotFunc)
.task { image = await generateImage(uri, tintColor: tintColor) } .onAppear {
image = image ?? generateImage(uri, tintColor: tintColor)
}
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
@@ -89,7 +89,7 @@ private func qrCodeImage(_ image: UIImage) -> some View {
.textSelection(.enabled) .textSelection(.enabled)
} }
private func generateImage(_ uri: String, tintColor: UIColor) async -> UIImage? { private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? {
let context = CIContext() let context = CIContext()
let filter = CIFilter.qrCodeGenerator() let filter = CIFilter.qrCodeGenerator()
filter.message = Data(uri.utf8) filter.message = Data(uri.utf8)
@@ -77,6 +77,7 @@ struct CreateProfile: View {
focusDisplayName = true focusDisplayName = true
} }
} }
.keyboardPadding()
} }
} }
@@ -127,6 +128,7 @@ struct CreateFirstProfile: View {
} }
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.keyboardPadding()
} }
func onboardingButtons() -> some View { func onboardingButtons() -> some View {
@@ -467,39 +467,6 @@ private let versionDescriptions: [VersionDescription] = [
) )
] ]
), ),
VersionDescription(
version: "v6.1",
post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"),
features: [
FeatureDescription(
icon: "checkmark.shield",
title: "Better security ✅",
description: "SimpleX protocols reviewed by Trail of Bits."
),
FeatureDescription(
icon: "video",
title: "Better calls",
description: "Switch audio and video during the call."
),
FeatureDescription(
icon: "bolt",
title: "Better notifications",
description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!"
),
FeatureDescription(
icon: nil,
title: "Better user experience",
description: nil,
subfeatures: [
("link", "Switch chat profile for 1-time invitations."),
("message", "Customizable message shape."),
("calendar", "Better message dates."),
("arrowshape.turn.up.right", "Forward up to 20 messages at once."),
("flag", "Delete or moderate up to 200 messages.")
]
),
]
),
] ]
private let lastVersion = versionDescriptions.last!.version private let lastVersion = versionDescriptions.last!.version
@@ -14,6 +14,7 @@ struct ConnectDesktopView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
var viaSettings = false
@AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name @AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name
@AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false @AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false
@AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = true @AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = true
@@ -56,6 +57,23 @@ struct ConnectDesktopView: View {
} }
var body: some View { var body: some View {
if viaSettings {
viewBody
.modifier(BackButton(label: "Back", disabled: Binding.constant(false)) {
if m.activeRemoteCtrl {
alert = .disconnectDesktop(action: .back)
} else {
dismiss()
}
})
} else {
NavigationView {
viewBody
}
}
}
var viewBody: some View {
Group { Group {
let discovery = m.remoteCtrlSession?.discovery let discovery = m.remoteCtrlSession?.discovery
if discovery == true || (discovery == nil && !showConnectScreen) { if discovery == true || (discovery == nil && !showConnectScreen) {
+2 -7
View File
@@ -105,12 +105,7 @@ struct TerminalView: View {
} }
} }
.navigationViewStyle(.stack) .navigationViewStyle(.stack)
.toolbar { .navigationTitle("Chat console")
// Redaction broken for `.navigationTitle` - using a toolbar item instead.
ToolbarItem(placement: .principal) {
Text("Chat console").font(.headline)
}
}
.modifier(ThemedBackground()) .modifier(ThemedBackground())
} }
@@ -165,7 +160,7 @@ struct TerminalView_Previews: PreviewProvider {
let chatModel = ChatModel() let chatModel = ChatModel()
chatModel.terminalItems = [ chatModel.terminalItems = [
.resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")), .resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")),
.resp(.now, ChatResponse.response(type: "newChatItems", json: "{}")) .resp(.now, ChatResponse.response(type: "newChatItem", json: "{}"))
] ]
return NavigationView { return NavigationView {
TerminalView() TerminalView()
@@ -36,10 +36,6 @@ struct AdvancedNetworkSettings: View {
@State private var showSettingsAlert: NetworkSettingsAlert? @State private var showSettingsAlert: NetworkSettingsAlert?
@State private var onionHosts: OnionHosts = .no @State private var onionHosts: OnionHosts = .no
@State private var showSaveDialog = false @State private var showSaveDialog = false
@State private var netProxy = networkProxyDefault.get()
@State private var currentNetProxy = networkProxyDefault.get()
@State private var useNetProxy = false
@State private var netProxyAuth = false
var body: some View { var body: some View {
VStack { VStack {
@@ -106,76 +102,6 @@ struct AdvancedNetworkSettings: View {
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
} }
Section {
Toggle("Use SOCKS proxy", isOn: $useNetProxy)
Group {
TextField("IP address", text: $netProxy.host)
TextField(
"Port",
text: Binding(
get: { netProxy.port > 0 ? "\(netProxy.port)" : "" },
set: { s in
netProxy.port = if let port = Int(s), port > 0 {
port
} else {
0
}
}
)
)
Toggle("Proxy requires password", isOn: $netProxyAuth)
if netProxyAuth {
TextField("Username", text: $netProxy.username)
PassphraseField(
key: $netProxy.password,
placeholder: "Password",
valid: NetworkProxy.validCredential(netProxy.password)
)
}
}
.if(!useNetProxy) { $0.foregroundColor(theme.colors.secondary) }
.disabled(!useNetProxy)
} header: {
HStack {
Text("SOCKS proxy").foregroundColor(theme.colors.secondary)
if useNetProxy && !netProxy.valid {
Spacer()
Image(systemName: "exclamationmark.circle.fill").foregroundColor(.red)
}
}
} footer: {
if netProxyAuth {
Text("Your credentials may be sent unencrypted.")
.foregroundColor(theme.colors.secondary)
} else {
Text("Do not use credentials with proxy.")
.foregroundColor(theme.colors.secondary)
}
}
.onChange(of: useNetProxy) { useNetProxy in
netCfg.socksProxy = useNetProxy && currentNetProxy.valid
? currentNetProxy.toProxyString()
: nil
netProxy = currentNetProxy
netProxyAuth = netProxy.username != "" || netProxy.password != ""
}
.onChange(of: netProxyAuth) { netProxyAuth in
if netProxyAuth {
netProxy.auth = currentNetProxy.auth
netProxy.username = currentNetProxy.username
netProxy.password = currentNetProxy.password
} else {
netProxy.auth = .username
netProxy.username = ""
netProxy.password = ""
}
}
.onChange(of: netProxy) { netProxy in
netCfg.socksProxy = useNetProxy && netProxy.valid
? netProxy.toProxyString()
: nil
}
Section { Section {
Picker("Use .onion hosts", selection: $onionHosts) { Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) } ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
@@ -196,14 +122,11 @@ struct AdvancedNetworkSettings: View {
if developerTools { if developerTools {
Section { Section {
Picker("Transport isolation", selection: $netCfg.sessionMode) { Picker("Transport isolation", selection: $netCfg.sessionMode) {
let modes = TransportSessionMode.values.contains(netCfg.sessionMode) ForEach(TransportSessionMode.values, id: \.self) { Text($0.text) }
? TransportSessionMode.values
: TransportSessionMode.values + [netCfg.sessionMode]
ForEach(modes, id: \.self) { Text($0.text) }
} }
.frame(height: 36) .frame(height: 36)
} footer: { } footer: {
sessionModeInfo(netCfg.sessionMode) Text(sessionModeInfo(netCfg.sessionMode))
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
} }
} }
@@ -233,19 +156,19 @@ struct AdvancedNetworkSettings: View {
Section { Section {
Button("Reset to defaults") { Button("Reset to defaults") {
updateNetCfgView(NetCfg.defaults, NetworkProxy.def) updateNetCfgView(NetCfg.defaults)
} }
.disabled(netCfg == NetCfg.defaults) .disabled(netCfg == NetCfg.defaults)
Button("Set timeouts for proxy/VPN") { Button("Set timeouts for proxy/VPN") {
updateNetCfgView(netCfg.withProxyTimeouts, netProxy) updateNetCfgView(netCfg.withProxyTimeouts)
} }
.disabled(netCfg.hasProxyTimeouts) .disabled(netCfg.hasProxyTimeouts)
Button("Save and reconnect") { Button("Save and reconnect") {
showSettingsAlert = .update showSettingsAlert = .update
} }
.disabled(netCfg == currentNetCfg || (useNetProxy && !netProxy.valid)) .disabled(netCfg == currentNetCfg)
} }
} }
} }
@@ -259,8 +182,7 @@ struct AdvancedNetworkSettings: View {
if cfgLoaded { return } if cfgLoaded { return }
cfgLoaded = true cfgLoaded = true
currentNetCfg = getNetCfg() currentNetCfg = getNetCfg()
currentNetProxy = networkProxyDefault.get() updateNetCfgView(currentNetCfg)
updateNetCfgView(currentNetCfg, currentNetProxy)
} }
.alert(item: $showSettingsAlert) { a in .alert(item: $showSettingsAlert) { a in
switch a { switch a {
@@ -284,7 +206,7 @@ struct AdvancedNetworkSettings: View {
if netCfg == currentNetCfg { if netCfg == currentNetCfg {
dismiss() dismiss()
cfgLoaded = false cfgLoaded = false
} else if !useNetProxy || netProxy.valid { } else {
showSaveDialog = true showSaveDialog = true
} }
}) })
@@ -299,26 +221,18 @@ struct AdvancedNetworkSettings: View {
} }
} }
private func updateNetCfgView(_ cfg: NetCfg, _ proxy: NetworkProxy) { private func updateNetCfgView(_ cfg: NetCfg) {
netCfg = cfg netCfg = cfg
netProxy = proxy
onionHosts = OnionHosts(netCfg: netCfg) onionHosts = OnionHosts(netCfg: netCfg)
enableKeepAlive = netCfg.enableKeepAlive enableKeepAlive = netCfg.enableKeepAlive
keepAliveOpts = netCfg.tcpKeepAlive ?? KeepAliveOpts.defaults keepAliveOpts = netCfg.tcpKeepAlive ?? KeepAliveOpts.defaults
useNetProxy = netCfg.socksProxy != nil
netProxyAuth = switch netProxy.auth {
case .username: netProxy.username != "" || netProxy.password != ""
case .isolate: false
}
} }
private func saveNetCfg() -> Bool { private func saveNetCfg() -> Bool {
do { do {
try setNetworkConfig(netCfg) try setNetworkConfig(netCfg)
currentNetCfg = netCfg currentNetCfg = netCfg
setNetCfg(netCfg, networkProxy: useNetProxy ? netProxy : nil) setNetCfg(netCfg)
currentNetProxy = netProxy
networkProxyDefault.set(netProxy)
return true return true
} catch let error { } catch let error {
let err = responseError(error) let err = responseError(error)
@@ -356,13 +270,10 @@ struct AdvancedNetworkSettings: View {
} }
} }
private func sessionModeInfo(_ mode: TransportSessionMode) -> Text { private func sessionModeInfo(_ mode: TransportSessionMode) -> LocalizedStringKey {
let userMode = Text("A separate TCP connection will be used **for each chat profile you have in the app**.") switch mode {
return switch mode { case .user: return "A separate TCP connection will be used **for each chat profile you have in the app**."
case .user: userMode case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail."
case .session: userMode + Text("\n") + Text("New SOCKS credentials will be used every time you start the app.")
case .server: userMode + Text("\n") + Text("New SOCKS credentials will be used for each server.")
case .entity: Text("A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.")
} }
} }
@@ -19,15 +19,9 @@ extension AppSettings {
val.hostMode = .publicHost val.hostMode = .publicHost
val.requiredHostMode = true val.requiredHostMode = true
} }
if val.socksProxy != nil {
val.socksProxy = networkProxy?.toProxyString()
setNetCfg(val, networkProxy: networkProxy)
} else {
val.socksProxy = nil val.socksProxy = nil
setNetCfg(val, networkProxy: nil) setNetCfg(val)
} }
}
if let val = networkProxy { networkProxyDefault.set(val) }
if let val = privacyEncryptLocalFiles { privacyEncryptLocalFilesGroupDefault.set(val) } if let val = privacyEncryptLocalFiles { privacyEncryptLocalFilesGroupDefault.set(val) }
if let val = privacyAskToApproveRelays { privacyAskToApproveRelaysGroupDefault.set(val) } if let val = privacyAskToApproveRelays { privacyAskToApproveRelaysGroupDefault.set(val) }
if let val = privacyAcceptImages { if let val = privacyAcceptImages {
@@ -58,12 +52,10 @@ extension AppSettings {
profileImageCornerRadiusGroupDefault.set(val) profileImageCornerRadiusGroupDefault.set(val)
def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
} }
if let val = uiChatItemRoundness { def.setValue(val, forKey: DEFAULT_CHAT_ITEM_ROUNDNESS)} if let val = uiColorScheme { def.setValue(val, forKey: DEFAULT_CURRENT_THEME) }
if let val = uiChatItemTail { def.setValue(val, forKey: DEFAULT_CHAT_ITEM_TAIL)} if let val = uiDarkColorScheme { def.setValue(val, forKey: DEFAULT_SYSTEM_DARK_THEME) }
if let val = uiColorScheme { currentThemeDefault.set(val) } if let val = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) }
if let val = uiDarkColorScheme { systemDarkThemeDefault.set(val) } if let val = uiThemes { def.setValue(val.skipDuplicates(), forKey: DEFAULT_THEME_OVERRIDES) }
if let val = uiCurrentThemeIds { currentThemeIdsDefault.set(val) }
if let val = uiThemes { themeOverridesDefault.set(val.skipDuplicates()) }
if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) } if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) }
} }
@@ -71,7 +63,6 @@ extension AppSettings {
let def = UserDefaults.standard let def = UserDefaults.standard
var c = AppSettings.defaults var c = AppSettings.defaults
c.networkConfig = getNetCfg() c.networkConfig = getNetCfg()
c.networkProxy = networkProxyDefault.get()
c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get() c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get()
c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get() c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get()
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
@@ -93,8 +84,6 @@ extension AppSettings {
c.iosCallKitEnabled = callKitEnabledGroupDefault.get() c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
c.uiChatItemRoundness = def.double(forKey: DEFAULT_CHAT_ITEM_ROUNDNESS)
c.uiChatItemTail = def.bool(forKey: DEFAULT_CHAT_ITEM_TAIL)
c.uiColorScheme = currentThemeDefault.get() c.uiColorScheme = currentThemeDefault.get()
c.uiDarkColorScheme = systemDarkThemeDefault.get() c.uiDarkColorScheme = systemDarkThemeDefault.get()
c.uiCurrentThemeIds = currentThemeIdsDefault.get() c.uiCurrentThemeIds = currentThemeIdsDefault.get()
@@ -33,8 +33,6 @@ struct AppearanceSettings: View {
}() }()
@State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName @State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var chatItemRoundness = defaultChatItemRoundness
@AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var chatItemTail = true
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@@ -181,14 +179,6 @@ struct AppearanceSettings: View {
} }
} }
Section(header: Text("Message shape").foregroundColor(theme.colors.secondary)) {
HStack {
Text("Corner")
Slider(value: $chatItemRoundness, in: 0...1, step: 0.05)
}
Toggle("Tail", isOn: $chatItemTail)
}
Section(header: Text("Profile images").foregroundColor(theme.colors.secondary)) { Section(header: Text("Profile images").foregroundColor(theme.colors.secondary)) {
HStack(spacing: 16) { HStack(spacing: 16) {
if let img = m.currentUser?.image, img != "" { if let img = m.currentUser?.image, img != "" {
@@ -367,22 +357,21 @@ struct ChatThemePreview: View {
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview")) let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir)) let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack { HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice) ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
.modifier(ChatItemClipped(alice, tailVisible: true)) .modifier(ChatItemClipped())
Spacer() Spacer()
} }
HStack { HStack {
Spacer() Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: bob) ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
.modifier(ChatItemClipped(bob, tailVisible: true)) .modifier(ChatItemClipped())
.frame(alignment: .trailing) .frame(alignment: .trailing)
} }
} else { } else {
Rectangle().fill(.clear) Rectangle().fill(.clear)
} }
} }
.padding(.vertical, 10) .padding(10)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
if let wallpaperType, let wallpaperImage = wallpaperType.image, let backgroundColor, let tintColor { if let wallpaperType, let wallpaperImage = wallpaperType.image, let backgroundColor, let tintColor {
@@ -583,14 +572,11 @@ struct CustomizeThemeView: View {
} }
} }
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: nil) ImportExportThemeSection(perChat: nil, perUser: nil, save: { theme in
}
.modifier(
ThemeImporter(isPresented: $showFileImporter) { theme in
ThemeManager.saveAndApplyThemeOverrides(theme) ThemeManager.saveAndApplyThemeOverrides(theme)
saveThemeToDatabase(nil) saveThemeToDatabase(nil)
})
} }
)
/// When changing app theme, user overrides are hidden. User overrides will be returned back after closing Appearance screen, see ThemeDestinationPicker() /// When changing app theme, user overrides are hidden. User overrides will be returned back after closing Appearance screen, see ThemeDestinationPicker()
.interactiveDismissDisabled(true) .interactiveDismissDisabled(true)
} }
@@ -598,9 +584,10 @@ struct CustomizeThemeView: View {
struct ImportExportThemeSection: View { struct ImportExportThemeSection: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var showFileImporter: Bool
var perChat: ThemeModeOverride? var perChat: ThemeModeOverride?
var perUser: ThemeModeOverrides? var perUser: ThemeModeOverrides?
var save: (ThemeOverrides) -> Void
@State private var showFileImporter = false
var body: some View { var body: some View {
Section { Section {
@@ -628,17 +615,8 @@ struct ImportExportThemeSection: View {
} label: { } label: {
Text("Import theme").foregroundColor(theme.colors.primary) Text("Import theme").foregroundColor(theme.colors.primary)
} }
} .fileImporter(
} isPresented: $showFileImporter,
}
struct ThemeImporter: ViewModifier {
@Binding var isPresented: Bool
var save: (ThemeOverrides) -> Void
func body(content: Content) -> some View {
content.fileImporter(
isPresented: $isPresented,
allowedContentTypes: [.data/*.plainText*/], allowedContentTypes: [.data/*.plainText*/],
allowsMultipleSelection: false allowsMultipleSelection: false
) { result in ) { result in
@@ -673,6 +651,7 @@ struct ThemeImporter: ViewModifier {
} }
} }
} }
}
} }
struct UserWallpaperEditorSheet: View { struct UserWallpaperEditorSheet: View {
@@ -818,7 +797,7 @@ struct ThemeDestinationPicker: View {
@Binding var customizeThemeIsOpen: Bool @Binding var customizeThemeIsOpen: Bool
var body: some View { var body: some View {
let values = [(nil, NSLocalizedString("All profiles", comment: "profile dropdown"))] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)} let values = [(nil, "All profiles")] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
if values.contains(where: { (userId, text) in userId == themeUserDestination?.0 }) { if values.contains(where: { (userId, text) in userId == themeUserDestination?.0 }) {
Picker("Apply to", selection: $themeUserDest) { Picker("Apply to", selection: $themeUserDest) {
@@ -26,7 +26,6 @@ struct IncognitoHelp: View {
Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).") Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).")
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
} }
.modifier(ThemedBackground()) .modifier(ThemedBackground())
} }
@@ -32,17 +32,6 @@ struct PreferencesView: View {
.disabled(currentPreferences == preferences) .disabled(currentPreferences == preferences)
} }
} }
.onDisappear {
if currentPreferences != preferences {
showAlert(
title: NSLocalizedString("Your chat preferences", comment: "alert title"),
message: NSLocalizedString("Chat preferences were changed.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: savePreferences,
cancelButton: true
)
}
}
} }
private func featureSection(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View { private func featureSection(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
@@ -47,8 +47,6 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration
let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius" let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
@@ -75,12 +73,8 @@ let DEFAULT_SYSTEM_DARK_THEME = "systemDarkTheme"
let DEFAULT_CURRENT_THEME_IDS = "currentThemeIds" let DEFAULT_CURRENT_THEME_IDS = "currentThemeIds"
let DEFAULT_THEME_OVERRIDES = "themeOverrides" let DEFAULT_THEME_OVERRIDES = "themeOverrides"
let DEFAULT_NETWORK_PROXY = "networkProxy"
let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen" let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen"
let defaultChatItemRoundness: Double = 0.75
let appDefaults: [String: Any] = [ let appDefaults: [String: Any] = [
DEFAULT_SHOW_LA_NOTICE: false, DEFAULT_SHOW_LA_NOTICE: false,
DEFAULT_LA_NOTICE_SHOWN: false, DEFAULT_LA_NOTICE_SHOWN: false,
@@ -104,8 +98,6 @@ let appDefaults: [String: Any] = [
DEFAULT_DEVELOPER_TOOLS: false, DEFAULT_DEVELOPER_TOOLS: false,
DEFAULT_ENCRYPTION_STARTED: false, DEFAULT_ENCRYPTION_STARTED: false,
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
DEFAULT_CHAT_ITEM_TAIL: true,
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
@@ -253,14 +245,13 @@ public class CodableDefault<T: Codable> {
} }
} }
let networkProxyDefault: CodableDefault<NetworkProxy> = CodableDefault(defaults: UserDefaults.standard, forKey: DEFAULT_NETWORK_PROXY, withDefault: NetworkProxy.def)
struct SettingsView: View { struct SettingsView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var sceneDelegate: SceneDelegate @EnvironmentObject var sceneDelegate: SceneDelegate
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var showSettings: Bool
@State private var showProgress: Bool = false @State private var showProgress: Bool = false
var body: some View { var body: some View {
@@ -269,12 +260,71 @@ struct SettingsView: View {
if showProgress { if showProgress {
progressView() progressView()
} }
if let la = chatModel.laRequest {
LocalAuthView(authRequest: la)
}
} }
} }
@ViewBuilder func settingsView() -> some View { @ViewBuilder func settingsView() -> some View {
let user = chatModel.currentUser let user = chatModel.currentUser
NavigationView {
List { List {
Section(header: Text("You").foregroundColor(theme.colors.secondary)) {
if let user = user {
NavigationLink {
UserProfile()
.navigationTitle("Your current profile")
.modifier(ThemedBackground())
} label: {
ProfilePreview(profileOf: user)
.padding(.leading, -8)
}
}
NavigationLink {
UserProfilesView(showSettings: $showSettings)
} label: {
settingsRow("person.crop.rectangle.stack", color: theme.colors.secondary) { Text("Your chat profiles") }
}
if let user = user {
NavigationLink {
UserAddressView(shareViaProfile: user.addressShared)
.navigationTitle("SimpleX address")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("qrcode", color: theme.colors.secondary) { Text("Your SimpleX address") }
}
NavigationLink {
PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
.navigationTitle("Your preferences")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("switch.2", color: theme.colors.secondary) { Text("Chat preferences") }
}
}
NavigationLink {
ConnectDesktopView(viaSettings: true)
} label: {
settingsRow("desktopcomputer", color: theme.colors.secondary) { Text("Use from desktop") }
}
NavigationLink {
MigrateFromDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress)
.navigationTitle("Migrate device")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
}
}
.disabled(chatModel.chatRunning != true)
Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) { Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) {
NavigationLink { NavigationLink {
NotificationsView() NotificationsView()
@@ -325,29 +375,14 @@ struct SettingsView: View {
} }
.disabled(chatModel.chatRunning != true) .disabled(chatModel.chatRunning != true)
} }
}
Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) {
chatDatabaseRow() chatDatabaseRow()
NavigationLink {
MigrateFromDevice(showProgressOnSettings: $showProgress)
.toolbar {
// Redaction broken for `.navigationTitle` - using a toolbar item instead.
ToolbarItem(placement: .principal) {
Text("Migrate device").font(.headline)
}
}
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
}
} }
Section(header: Text("Help").foregroundColor(theme.colors.secondary)) { Section(header: Text("Help").foregroundColor(theme.colors.secondary)) {
if let user = user { if let user = user {
NavigationLink { NavigationLink {
ChatHelp(dismissSettingsSheet: dismiss) ChatHelp(showSettings: $showSettings)
.navigationTitle("Welcome \(user.displayName)!") .navigationTitle("Welcome \(user.displayName)!")
.modifier(ThemedBackground()) .modifier(ThemedBackground())
.frame(maxHeight: .infinity, alignment: .top) .frame(maxHeight: .infinity, alignment: .top)
@@ -372,7 +407,7 @@ struct SettingsView: View {
} }
settingsRow("number", color: theme.colors.secondary) { settingsRow("number", color: theme.colors.secondary) {
Button("Send questions and ideas") { Button("Send questions and ideas") {
dismiss() showSettings = false
DispatchQueue.main.async { DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL) UIApplication.shared.open(simplexTeamURL)
} }
@@ -421,6 +456,7 @@ struct SettingsView: View {
} }
.navigationTitle("Your settings") .navigationTitle("Your settings")
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
}
.onDisappear { .onDisappear {
chatModel.showingTerminal = false chatModel.showingTerminal = false
chatModel.terminalItems = [] chatModel.terminalItems = []
@@ -429,7 +465,7 @@ struct SettingsView: View {
private func chatDatabaseRow() -> some View { private func chatDatabaseRow() -> some View {
NavigationLink { NavigationLink {
DatabaseView(dismissSettingsSheet: dismiss, chatItemTTL: chatModel.chatItemTTL) DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL)
.navigationTitle("Your chat database") .navigationTitle("Your chat database")
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} label: { } label: {
@@ -507,25 +543,26 @@ struct ProfilePreview: View {
HStack { HStack {
ProfileImage(imageStr: profileOf.image, size: 44, color: color) ProfileImage(imageStr: profileOf.image, size: 44, color: color)
.padding(.trailing, 6) .padding(.trailing, 6)
profileName(profileOf).lineLimit(1) .padding(.vertical, 6)
} VStack(alignment: .leading) {
} Text(profileOf.displayName)
} .fontWeight(.bold)
.font(.title2)
func profileName(_ profileOf: NamedChat) -> Text {
var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2)
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
t = t + Text(" (" + profileOf.fullName + ")") Text(profileOf.fullName)
// .font(.callout) }
}
}
} }
return t
} }
struct SettingsView_Previews: PreviewProvider { struct SettingsView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let chatModel = ChatModel() let chatModel = ChatModel()
chatModel.currentUser = User.sampleData chatModel.currentUser = User.sampleData
return SettingsView() @State var showSettings = false
return SettingsView(showSettings: $showSettings)
.environmentObject(chatModel) .environmentObject(chatModel)
} }
} }
@@ -14,6 +14,7 @@ struct UserAddressView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var chatModel: ChatModel @EnvironmentObject private var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@State var viaCreateLinkView = false
@State var shareViaProfile = false @State var shareViaProfile = false
@State private var aas = AutoAcceptState() @State private var aas = AutoAcceptState()
@State private var savedAAS = AutoAcceptState() @State private var savedAAS = AutoAcceptState()
@@ -21,6 +22,7 @@ struct UserAddressView: View {
@State private var showMailView = false @State private var showMailView = false
@State private var mailViewResult: Result<MFMailComposeResult, Error>? = nil @State private var mailViewResult: Result<MFMailComposeResult, Error>? = nil
@State private var alert: UserAddressAlert? @State private var alert: UserAddressAlert?
@State private var showSaveDialogue = false
@State private var progressIndicator = false @State private var progressIndicator = false
@FocusState private var keyboardVisible: Bool @FocusState private var keyboardVisible: Bool
@@ -42,19 +44,26 @@ struct UserAddressView: View {
var body: some View { var body: some View {
ZStack { ZStack {
if viaCreateLinkView {
userAddressScrollView() userAddressScrollView()
.onDisappear { } else {
if savedAAS != aas { userAddressScrollView()
showAlert( .modifier(BackButton(disabled: Binding.constant(false)) {
title: NSLocalizedString("Auto-accept settings", comment: "alert title"), if savedAAS == aas {
message: NSLocalizedString("Settings were changed.", comment: "alert message"), dismiss()
buttonTitle: NSLocalizedString("Save", comment: "alert button"), } else {
buttonAction: saveAAS, keyboardVisible = false
cancelButton: true showSaveDialogue = true
) }
})
.confirmationDialog("Save settings?", isPresented: $showSaveDialogue) {
Button("Save auto-accept settings") {
saveAAS()
dismiss()
}
Button("Exit without saving") { dismiss() }
} }
} }
if progressIndicator { if progressIndicator {
ZStack { ZStack {
if chatModel.userAddress != nil { if chatModel.userAddress != nil {
@@ -438,8 +447,6 @@ struct UserAddressView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let chatModel = ChatModel() let chatModel = ChatModel()
chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D") chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
return Group { return Group {
UserAddressView() UserAddressView()
.environmentObject(chatModel) .environmentObject(chatModel)
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More