Compare commits
759 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83fd6efed9 | |||
| c8936220b0 | |||
| 0509ff9a1b | |||
| f12e504828 | |||
| 6a273677f1 | |||
| f5eea018d9 | |||
| 5f96f6c117 | |||
| e963ef53e7 | |||
| 1180edf80e | |||
| 4bd6517d19 | |||
| 2585f4ecfd | |||
| e22e01acd2 | |||
| 8e4299afb6 | |||
| ecff3c6ee5 | |||
| 85af368371 | |||
| 0d3928bd51 | |||
| ddeaa1c7c3 | |||
| 00ba468898 | |||
| 593c7d247c | |||
| 5907d8bd0c | |||
| 231082860e | |||
| c6366cb269 | |||
| d066c66282 | |||
| fab3827697 | |||
| d8126e0cd0 | |||
| 5baf8789d6 | |||
| dd5454f390 | |||
| fca098f535 | |||
| fba0478e50 | |||
| d951003191 | |||
| 1af513c548 | |||
| 85dc467f74 | |||
| 89e65c2fc1 | |||
| 0fa8d77214 | |||
| 6ba82c8c39 | |||
| e19c14b249 | |||
| d993381d31 | |||
| 0198c1f110 | |||
| 6c4c9b172c | |||
| d9a61af401 | |||
| a58522ef15 | |||
| bcd510c913 | |||
| c4083e00c5 | |||
| 7cd35b9068 | |||
| d3274b28ba | |||
| 40bd5dc2da | |||
| 1573b7af13 | |||
| a5bf3cbca8 | |||
| d5529031ee | |||
| d244338b13 | |||
| ce55c84b96 | |||
| c52f9baefa | |||
| ea1c5da65d | |||
| 5695bb9d46 | |||
| 13751d5705 | |||
| 02803b1491 | |||
| 222af69a71 | |||
| 5e96e1ea2b | |||
| 490e8cead8 | |||
| f578ee843b | |||
| cf2eb76cb7 | |||
| 48f9dcab9a | |||
| ac2ef54838 | |||
| 7e269c6b8c | |||
| 3e739924bb | |||
| 83706b19da | |||
| 6f19a7a20f | |||
| a8740cee4b | |||
| 4000fe383d | |||
| 75445e604e | |||
| 0596278e50 | |||
| 7a77775a42 | |||
| dc8d08ec04 | |||
| 4ad0e0f65e | |||
| d01515e755 | |||
| 3fa70ba217 | |||
| 6ae4cddc2e | |||
| ac8ee971f5 | |||
| 11d4872186 | |||
| 370e2e87ad | |||
| ef30fcf5e2 | |||
| 6ad1423f29 | |||
| c52e5349db | |||
| 3c98c6acc0 | |||
| c251f5e2bd | |||
| b165603136 | |||
| 184a24b70e | |||
| d46bae6498 | |||
| 5ac521f6d8 | |||
| 7b5b747b19 | |||
| 037655e30f | |||
| 54088c6d5a | |||
| 78f7fcc89f | |||
| b402caeb89 | |||
| ff8a8de3c8 | |||
| d22c0c2614 | |||
| 73c8d3c2e3 | |||
| 0110385e65 | |||
| ad0a678c5f | |||
| 0495a4cd78 | |||
| 02267c5263 | |||
| cad3bc048f | |||
| 1b04423745 | |||
| 2143eb2d7a | |||
| 3d395b0e45 | |||
| d79b4dfb5a | |||
| 0ae9f5ebba | |||
| 57c80786dc | |||
| da1cad96fc | |||
| b58ccedf23 | |||
| 01cadefde7 | |||
| c70e7223d9 | |||
| a1d4f52185 | |||
| 373070f388 | |||
| a03ccd0ba6 | |||
| a7982cccf9 | |||
| dccd6631af | |||
| b0516b6440 | |||
| bf91666f07 | |||
| ab47a5a27e | |||
| 23581f59ae | |||
| 291df6e9d0 | |||
| dae0b63c22 | |||
| 0964982782 | |||
| 9eb6034e1a | |||
| c59eb06b8a | |||
| 423fc96638 | |||
| ba203faad4 | |||
| ec7b35adb9 | |||
| d2d450d1d7 | |||
| b4caefb17c | |||
| fd791f3fb8 | |||
| 0720292ebf | |||
| a0d6ae15ab | |||
| 3e76a2d49c | |||
| ed3365bb6d | |||
| b2a8797ed4 | |||
| d3e522e169 | |||
| 49e09ca449 | |||
| d1dde3d0c8 | |||
| ef75eceb40 | |||
| 96ce59f330 | |||
| 98a67fa2e4 | |||
| 4b13512950 | |||
| 4c0d47bbd4 | |||
| 3129602a78 | |||
| a0c257962e | |||
| 93ae1145bc | |||
| d02668386f | |||
| bd8c8b5a8c | |||
| e359ec02f5 | |||
| a1948d549b | |||
| 396b73b480 | |||
| 331b4633fc | |||
| 29db2b4e9b | |||
| f091384aa7 | |||
| f40ba6f04d | |||
| 06d61ea73e | |||
| 84d7a77a9f | |||
| 878eea774d | |||
| 4575dc6e30 | |||
| 554ef4eefc | |||
| 567e9981be | |||
| 1183fe427f | |||
| c49a1f42e7 | |||
| 4f173215c6 | |||
| 41597e563e | |||
| aa127f0cea | |||
| f9c64ba39c | |||
| 1025fa94a1 | |||
| a59769b91d | |||
| 76248b4878 | |||
| 93a7ddb128 | |||
| bc5af35a3e | |||
| e38d5bd885 | |||
| 10f41aa1c9 | |||
| 1b081197d4 | |||
| 9615e525c0 | |||
| db136116d3 | |||
| 98f5e5d22b | |||
| 9f33e5d35e | |||
| b8e8124faf | |||
| 156e41950a | |||
| 26c5ed4caf | |||
| d832afa7e8 | |||
| 2e18b97077 | |||
| b6e57c0fa2 | |||
| 15a226cfd1 | |||
| c4cbf751b9 | |||
| 535ff1d836 | |||
| fb0718adac | |||
| 5f501f1cb9 | |||
| 7cc86574fe | |||
| 19fc44efae | |||
| 37e03a838c | |||
| aeb28400e9 | |||
| d5a0c5e56a | |||
| a28d965a65 | |||
| 9148425eba | |||
| 406c35958e | |||
| be9ae61a32 | |||
| 9db65a1775 | |||
| 1033f55597 | |||
| 7f2462c936 | |||
| e675efe6f3 | |||
| b3f8068446 | |||
| 957eda8a78 | |||
| 0fe949e225 | |||
| 5325b0a543 | |||
| f712eb0d1f | |||
| fc6fa5315b | |||
| 3eb88ab896 | |||
| 20fa366231 | |||
| db05436e50 | |||
| a1093b0671 | |||
| 6a8a8f242a | |||
| 1163fe7b56 | |||
| 063a6dbc92 | |||
| 9e2a3fe848 | |||
| 2b7f3099a6 | |||
| cfa7e0bb28 | |||
| 1967ac0880 | |||
| 68966a1551 | |||
| 5ba7b58d7d | |||
| 9979983742 | |||
| ab76e93df7 | |||
| f30287c4df | |||
| 2807e53d4c | |||
| 642e6d677e | |||
| 944a0b3aad | |||
| e5ac472da3 | |||
| 2f79bfb075 | |||
| ae9e63d1be | |||
| d864603d01 | |||
| 06ac15b282 | |||
| 4c1dfc76d4 | |||
| bea19f76e9 | |||
| f80ac6dc50 | |||
| 2b6764fc36 | |||
| 7c74f02d01 | |||
| d53aa69691 | |||
| 9e3f528d44 | |||
| 22fd2d7189 | |||
| 7567791866 | |||
| 529ba7a0e7 | |||
| 3b3a68da15 | |||
| e0e94d78b0 | |||
| d68369b32a | |||
| d29886dbd2 | |||
| 50d3a29715 | |||
| 8e3a713a49 | |||
| 7cec79e79b | |||
| e9db7e8e58 | |||
| f53882049f | |||
| 8f4c9a673a | |||
| 02f980e968 | |||
| 412f75219a | |||
| c8c81a840b | |||
| 925cb39397 | |||
| f6ceb89c4b | |||
| a35d392f76 | |||
| 1306c84938 | |||
| 6484e36b82 | |||
| 918be31f2c | |||
| 648884044f | |||
| 505c6cb062 | |||
| 065c8f8861 | |||
| 6c509027f7 | |||
| 4eafdc3723 | |||
| 77f5c678d5 | |||
| 5e71d00ace | |||
| c2169f7813 | |||
| 7ff4b05c90 | |||
| d7f8bff957 | |||
| bda0917787 | |||
| 0eae7f56f3 | |||
| 24e3806b25 | |||
| f606e5742c | |||
| c6af2c285a | |||
| 35b7f2cb1c | |||
| 76a33a3743 | |||
| b6fd5e61b6 | |||
| fe639eed48 | |||
| 638fd3756a | |||
| de81434b36 | |||
| 433b3652fb | |||
| 86fe2212c3 | |||
| 182225a5b1 | |||
| e10c63dc64 | |||
| 29941729d2 | |||
| ee53377070 | |||
| 0deb6a4eb1 | |||
| b84ef0ce35 | |||
| 34dee32def | |||
| 94851f177b | |||
| e560b49d14 | |||
| d8b52ee0d3 | |||
| a9c0f356b4 | |||
| 8a3710482b | |||
| b3a4ea8e57 | |||
| 4be6c69633 | |||
| 005d99ad50 | |||
| 36f86beafd | |||
| 1fa79a2654 | |||
| 72611084d3 | |||
| a5db36469d | |||
| f8e6a78a3b | |||
| 5b0c8e0f76 | |||
| 2968cd0460 | |||
| 8ffb4e500e | |||
| 6133993cfe | |||
| 0d7a32877f | |||
| 18efc28d16 | |||
| 069395c2a0 | |||
| a725d2efac | |||
| 5f9710e4bb | |||
| ea6c5bfb0b | |||
| 14c279d1e0 | |||
| d2de81100d | |||
| 9b28ae6d9e | |||
| ea862a8f34 | |||
| 2bd1a82b7d | |||
| 97a37634ef | |||
| 28fbc1cd84 | |||
| b8ee2af5b7 | |||
| c234809894 | |||
| e1a997877a | |||
| 69218952c3 | |||
| dc2591b0bf | |||
| 2272b77b53 | |||
| d90e2f4436 | |||
| 9b6ca23dcb | |||
| 1410b4bd85 | |||
| 42a35e8c72 | |||
| 86fe28f1ed | |||
| 735359c279 | |||
| 3d20465662 | |||
| b64ae90850 | |||
| 366b58ead5 | |||
| 32bcaad126 | |||
| d65137882b | |||
| 095d613f01 | |||
| f22beefbd4 | |||
| c9bf36291b | |||
| 42ebe8bc60 | |||
| 96fb61ccfc | |||
| 1d698c114f | |||
| 154ce5843d | |||
| a5817d9feb | |||
| 72082af139 | |||
| eb36704f59 | |||
| 84fbff6148 | |||
| dd75d30c08 | |||
| 52803ae976 | |||
| ec73a31cb5 | |||
| 6ad5048b58 | |||
| ca0ce4dfdd | |||
| d27502ec47 | |||
| 655ad4d813 | |||
| e0566599a4 | |||
| 2546ad5351 | |||
| 0706e6e464 | |||
| c2adf40b5a | |||
| b220ea23e2 | |||
| a16e3eebd1 | |||
| 6d54c21774 | |||
| d2e6313ce3 | |||
| c2707d3c2b | |||
| 43381b5cf0 | |||
| 3f1d86623a | |||
| 5fde879fea | |||
| 157c4722ca | |||
| 5bc8bbe16c | |||
| d835f9df3d | |||
| 8f04e13750 | |||
| 39167ab3dd | |||
| e925d87548 | |||
| c2e2408394 | |||
| bb16922634 | |||
| a3310cd62e | |||
| 94a6f300ed | |||
| 1f8eb1d638 | |||
| 5a016378a8 | |||
| bac4e61997 | |||
| f3eeb9dcc2 | |||
| 240ca30f91 | |||
| 835944ab24 | |||
| d53ef24bf1 | |||
| e75be71d9a | |||
| 7fa2f2f72e | |||
| 5fd8e6e4fe | |||
| d3b255b7cb | |||
| 96fba950ff | |||
| 4a404f14d9 | |||
| 4f893d9502 | |||
| 01447716fa | |||
| c9df591e52 | |||
| a56bc6760b | |||
| 80690326cb | |||
| 3f6c74f975 | |||
| 8b8846c7b7 | |||
| 6c78bbc178 | |||
| eaf720a0f2 | |||
| 0e7d81681f | |||
| 56fcaf514e | |||
| 49bd866c4b | |||
| 8660bf420a | |||
| 60a73a539e | |||
| 93d56a25bc | |||
| 7fb3c4abdb | |||
| 191d833947 | |||
| 19ca4f7447 | |||
| b86b5578de | |||
| 435ea9a453 | |||
| 1f93d91af5 | |||
| 405348732b | |||
| 109b6e0cff | |||
| b403201310 | |||
| 9ff11f886e | |||
| bc2b135880 | |||
| ce9b909495 | |||
| f1c22a3308 | |||
| 7b7c3227e3 | |||
| 2c6e4db603 | |||
| 8d72757f62 | |||
| 61a3eb32ee | |||
| 64dc758ffd | |||
| eebf014ff7 | |||
| 2155060ad0 | |||
| db2ccaa450 | |||
| 4b7458b58f | |||
| 05383477d9 | |||
| 0c4848ad9e | |||
| b66a3d0595 | |||
| 8ac7677648 | |||
| c27973d202 | |||
| 51a2e09714 | |||
| ec8ae9febe | |||
| 7213913d51 | |||
| e37654772f | |||
| b7709c59d3 | |||
| 5807a0a2fa | |||
| 395654098c | |||
| 2d643e8d29 | |||
| 4e9703f0ff | |||
| b0b249a56a | |||
| 92c89632d4 | |||
| d54b453b49 | |||
| e6e27db243 | |||
| b629c22ee0 | |||
| f7d7f5461f | |||
| c544a636f6 | |||
| 73de74d7e9 | |||
| 654a7885c3 | |||
| daf67c0456 | |||
| e361bcf140 | |||
| 5de9087207 | |||
| 364b62320b | |||
| d83a6b7133 | |||
| cd21a74b83 | |||
| 6d523d5b4b | |||
| 2a321b3ff8 | |||
| 865a32c608 | |||
| e3df7945d5 | |||
| bb1620d7d2 | |||
| edc5a4c31b | |||
| 4260c20012 | |||
| 1a7efbc333 | |||
| e4984cb38d | |||
| dfa9775d7e | |||
| e39544dd24 | |||
| 71bcfc2848 | |||
| 91f10c056f | |||
| 3d8d84f978 | |||
| fec34ca875 | |||
| 3a0920e950 | |||
| f4ae60756c | |||
| eedc1b2860 | |||
| 24a35698dc | |||
| 7e37155938 | |||
| c8b38183c9 | |||
| 90a866ca56 | |||
| 7e9e71ffbd | |||
| 5da8aef794 | |||
| 09bbaa1c94 | |||
| 3a879b755b | |||
| c6f4d62d6c | |||
| 5ad356172f | |||
| 0a6c464a8d | |||
| 1aa464bdb2 | |||
| bce829ef58 | |||
| 7df300cf36 | |||
| 9b35ff6f11 | |||
| c4cbb49f57 | |||
| 2b3eebb7a2 | |||
| a1328c287c | |||
| f2d498dd79 | |||
| 6b8fc6fdcf | |||
| 0e585d5e5b | |||
| 520d8868ef | |||
| 7192448303 | |||
| 0f0f65533a | |||
| 78a38cb080 | |||
| f102f39147 | |||
| cd349e80ce | |||
| 430dc5bd2e | |||
| 3e0b863b64 | |||
| d6afee11bc | |||
| 6ef3a9e668 | |||
| fbe3353434 | |||
| d6d2e6e1eb | |||
| 14d5078404 | |||
| da1d20c17f | |||
| afc324dc4f | |||
| f81e457e09 | |||
| bd30b80e15 | |||
| da9a7f4642 | |||
| 838a759a76 | |||
| f1ff27218c | |||
| 8738cf332f | |||
| 4f602d4571 | |||
| d0b75e0dd3 | |||
| 1bb98706a6 | |||
| 6b7c13f20a | |||
| 93d2bb98b5 | |||
| 3b614e95e3 | |||
| 1a9e942e33 | |||
| bddb9c14b7 | |||
| afd145c979 | |||
| 97fbf2b7fe | |||
| db93d29e76 | |||
| 10b3fe1390 | |||
| a2b9a04430 | |||
| 444b92282e | |||
| c35d275273 | |||
| 052ef2037a | |||
| 0b731d818b | |||
| 1aab9f7f8a | |||
| bdf3ac1a36 | |||
| 8159ae4aea | |||
| a9ba0a2e8a | |||
| c65a17fccb | |||
| 5aa4b7687e | |||
| 1016513dd3 | |||
| 106556812c | |||
| 7d2f6a3609 | |||
| f188118643 | |||
| cc05434b31 | |||
| 68de2b7540 | |||
| 5d8bb24d1c | |||
| ab9a6dcab5 | |||
| 0dd1f13d3b | |||
| 16f53490c5 | |||
| 4e502c4d13 | |||
| 5236e0f201 | |||
| b69b72a93c | |||
| 2dae9180ec | |||
| ec57529f12 | |||
| c4d75366b5 | |||
| ddcd7d495e | |||
| b5fe1f8364 | |||
| 3c7e37ee9d | |||
| 2884ad9fde | |||
| c130905efa | |||
| 915dc46bdc | |||
| 1eb1ed92f7 | |||
| 236daf4391 | |||
| 13feffb33a | |||
| 06d36751d7 | |||
| c25e9e3b72 | |||
| 868acd18d6 | |||
| 8c1114648a | |||
| 3918c5306d | |||
| ab8a87acad | |||
| 809dd1ef01 | |||
| b874cd1910 | |||
| 300223b32e | |||
| 88640b85c4 | |||
| d5cf9fbf5b | |||
| f4f8501eb8 | |||
| be0c791c43 | |||
| 46fe0fc671 | |||
| bfb274b037 | |||
| 8d7dcb550a | |||
| 135e735dd4 | |||
| 516410a899 | |||
| dad9716915 | |||
| bc8a6f4833 | |||
| 5b7a09f488 | |||
| bfe5d51df7 | |||
| d9d270f00e | |||
| 2872b30e8c | |||
| f45b24367c | |||
| 9e369c3ac9 | |||
| afbb3b4e4b | |||
| acd05c43db | |||
| 61b14b22d5 | |||
| 25a4719414 | |||
| f802ec75fe | |||
| 045b195483 | |||
| 283c90f5ae | |||
| 99a9fb2e1f | |||
| ce9d583b39 | |||
| 53414608db | |||
| c7cf206585 | |||
| 6067ac3c93 | |||
| fc56873f1c | |||
| a30da38af7 | |||
| 0bf3d054c6 | |||
| a55a8b116a | |||
| 7a207fd641 | |||
| 4508e0dfc1 | |||
| a853ba3a15 | |||
| a2f190a6c6 | |||
| 267178dddb | |||
| fadce0c140 | |||
| 58ad97fe6d | |||
| 3ccd9903a7 | |||
| e294999044 | |||
| 2bbc687f4a | |||
| 61c507e7da | |||
| 64230f3545 | |||
| bb61b9c658 | |||
| 3428f4d2ee | |||
| fe865c5e11 | |||
| 9e87fe73a5 | |||
| 0ef2c55983 | |||
| 8882284fb7 | |||
| 767522e701 | |||
| 575d899f5a | |||
| d009777901 | |||
| f758a5526a | |||
| e6b5727003 | |||
| c9b1d54f13 | |||
| 05065e919b | |||
| 5399212e48 | |||
| 809040c7bc | |||
| 4ab078bd18 | |||
| 825257e898 | |||
| 644169b835 | |||
| 78eefee6cc | |||
| e253c55ba4 | |||
| 478bb32cdb | |||
| 1438fd00e2 | |||
| 05b55d3fb5 | |||
| 9c061508a4 | |||
| 2bacc00a06 | |||
| d637714963 | |||
| 5ff6bd15f6 | |||
| 5b52d0e173 | |||
| b4dd6941d7 | |||
| 07334b057d | |||
| 51bf2f413c | |||
| e3a69b12ba | |||
| b220b5f6ec | |||
| 0b8346701c | |||
| efc873b09b | |||
| 9f71502b51 | |||
| bbde6d81ee | |||
| 58906e1a60 | |||
| ed3d234826 | |||
| dded56d8b8 | |||
| 4d5aefa82c | |||
| 9ac99ec2d9 | |||
| 37d033c7a5 | |||
| 5798efcf71 | |||
| e086719f27 | |||
| bb4293eb5e | |||
| c3c66182f2 | |||
| 5b90d92ca2 | |||
| af22348bf8 | |||
| 5a6670998c | |||
| 700f6fa663 | |||
| d474cae705 | |||
| 2834b192ce | |||
| b8cb954882 | |||
| 6aeef6f132 | |||
| fa1702a566 | |||
| 95d6df926c | |||
| cccd517277 | |||
| 12d1ada25e | |||
| f93f68e425 | |||
| 23989aca57 | |||
| 57a6e85668 | |||
| 67590f3258 | |||
| c83238c35a | |||
| 8b0d2dede7 | |||
| c4855313b6 | |||
| 2bff3b9c97 | |||
| aa037c0662 | |||
| d198d6a8db | |||
| 7bcda7e54b | |||
| 4a4d470859 | |||
| 6ba3100d34 | |||
| 7b073ba9f8 | |||
| 5e042d222e | |||
| 26a189917b | |||
| ce9218b186 | |||
| f0338a03d1 | |||
| 6fa0001ea7 | |||
| 974fa448b4 | |||
| 8cec5428ee | |||
| 73130bf321 | |||
| 67241ff65c | |||
| b6b041490f | |||
| 7f9f9a674c | |||
| ae94bb6f87 | |||
| 7ec39d1ffa | |||
| ca6dfb5ea1 | |||
| a5048db6fa | |||
| aca3a71b38 | |||
| d358390e3d | |||
| f9a125bc32 | |||
| 35c1975d66 | |||
| 0bfe37137c | |||
| 666903ae76 | |||
| 8a41a4c214 | |||
| 79a954336c | |||
| f65b8a9e78 | |||
| e8016adfdc | |||
| d3059afc99 | |||
| 2f7632a70f | |||
| 27c14f32f1 | |||
| 13a32f7864 | |||
| b1652b8930 | |||
| a9b36e8e39 | |||
| ee163a6540 | |||
| 4fd6405113 | |||
| ccc62274ee | |||
| 4c6d52ba75 | |||
| 9df63160e5 | |||
| c8e9788c29 | |||
| 7099776357 | |||
| 3481d379c6 | |||
| 85c1e871dc | |||
| fec5ff3f15 | |||
| acaa597c90 | |||
| 6a9a67db14 | |||
| f94c0311c1 | |||
| e1ff7c88d7 | |||
| 9a1c7f41f7 | |||
| 40e69ae713 | |||
| b74e33b958 | |||
| 540c8883a0 | |||
| 0e18b13bea | |||
| a4b44254bc | |||
| 5819e42305 | |||
| 9580b4110d | |||
| 05a64c99a2 | |||
| 6a21d5c7f1 | |||
| 950bbe19da | |||
| 05278e5a06 | |||
| 7a54d74517 | |||
| bfcb2ac230 | |||
| 3073c4a1d5 | |||
| d4ac1c0cf2 | |||
| d29f1bb0cf | |||
| 75c2de8a12 | |||
| f20ac33e67 |
@@ -9,6 +9,7 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
- "!*-fdroid"
|
- "!*-fdroid"
|
||||||
|
- "!*-armv7a"
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -42,7 +43,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: build-${{ matrix.os }}
|
name: build-${{ matrix.os }}-${{ matrix.ghc }}
|
||||||
if: always()
|
if: always()
|
||||||
needs: prepare-release
|
needs: prepare-release
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
@@ -51,18 +52,30 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: ubuntu-20.04
|
- os: ubuntu-20.04
|
||||||
|
ghc: "8.10.7"
|
||||||
|
cache_path: ~/.cabal/store
|
||||||
|
- os: ubuntu-20.04
|
||||||
|
ghc: "9.6.3"
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||||
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
|
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
|
ghc: "9.6.3"
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||||
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
|
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
|
ghc: "9.6.3"
|
||||||
|
cache_path: ~/.cabal/store
|
||||||
|
asset_name: simplex-chat-macos-aarch64
|
||||||
|
desktop_asset_name: simplex-desktop-macos-aarch64.dmg
|
||||||
|
- os: macos-13
|
||||||
|
ghc: "9.6.3"
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-macos-x86-64
|
asset_name: simplex-chat-macos-x86-64
|
||||||
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
|
desktop_asset_name: simplex-desktop-macos-x86_64.dmg
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
|
ghc: "9.6.3"
|
||||||
cache_path: C:/cabal
|
cache_path: C:/cabal
|
||||||
asset_name: simplex-chat-windows-x86-64
|
asset_name: simplex-chat-windows-x86-64
|
||||||
desktop_asset_name: simplex-desktop-windows-x86_64.msi
|
desktop_asset_name: simplex-desktop-windows-x86_64.msi
|
||||||
@@ -81,16 +94,17 @@ jobs:
|
|||||||
- name: Setup Haskell
|
- name: Setup Haskell
|
||||||
uses: haskell-actions/setup@v2
|
uses: haskell-actions/setup@v2
|
||||||
with:
|
with:
|
||||||
ghc-version: "9.6.3"
|
ghc-version: ${{ matrix.ghc }}
|
||||||
cabal-version: "3.10.1.0"
|
cabal-version: "3.10.1.0"
|
||||||
|
|
||||||
- name: Cache dependencies
|
- name: Restore cached build
|
||||||
uses: actions/cache@v3
|
id: restore_cache
|
||||||
|
uses: actions/cache/restore@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ matrix.cache_path }}
|
${{ matrix.cache_path }}
|
||||||
dist-newstyle
|
dist-newstyle
|
||||||
key: ${{ matrix.os }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
|
key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
|
||||||
|
|
||||||
# / Unix
|
# / Unix
|
||||||
|
|
||||||
@@ -98,18 +112,36 @@ jobs:
|
|||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "ignore-project: False" >> cabal.project.local
|
echo "ignore-project: False" >> cabal.project.local
|
||||||
echo "package direct-sqlcipher" >> cabal.project.local
|
echo "package simplexmq" >> cabal.project.local
|
||||||
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
|
echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
|
||||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
|
||||||
echo " flags: +openssl" >> cabal.project.local
|
echo "" >> cabal.project.local
|
||||||
|
echo "package direct-sqlcipher" >> cabal.project.local
|
||||||
|
echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
|
||||||
|
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
|
||||||
|
echo " flags: +openssl" >> cabal.project.local
|
||||||
|
|
||||||
|
- name: Unix prepare cabal.project.local for Mac
|
||||||
|
if: matrix.os == 'macos-13'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "ignore-project: False" >> cabal.project.local
|
||||||
|
echo "package simplexmq" >> cabal.project.local
|
||||||
|
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
|
||||||
|
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||||
|
echo "" >> cabal.project.local
|
||||||
|
echo "package direct-sqlcipher" >> cabal.project.local
|
||||||
|
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
|
||||||
|
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||||
|
echo " flags: +openssl" >> cabal.project.local
|
||||||
|
|
||||||
- name: Install AppImage dependencies
|
- name: Install AppImage dependencies
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||||
run: sudo apt install -y desktop-file-utils
|
run: sudo apt install -y desktop-file-utils
|
||||||
|
|
||||||
- name: Install pkg-config for Mac
|
- name: Install pkg-config for Mac
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest' || matrix.os == 'macos-13'
|
||||||
run: brew install pkg-config
|
run: brew install pkg-config
|
||||||
|
|
||||||
- name: Unix prepare cabal.project.local for Ubuntu
|
- name: Unix prepare cabal.project.local for Ubuntu
|
||||||
@@ -131,7 +163,7 @@ jobs:
|
|||||||
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Unix upload CLI binary to release
|
- name: Unix upload CLI binary to release
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -140,7 +172,7 @@ jobs:
|
|||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Unix update CLI binary hash
|
- name: Unix update CLI binary hash
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -150,7 +182,7 @@ jobs:
|
|||||||
${{ steps.unix_cli_build.outputs.bin_hash }}
|
${{ steps.unix_cli_build.outputs.bin_hash }}
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
distribution: 'corretto'
|
distribution: 'corretto'
|
||||||
@@ -159,7 +191,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Linux build desktop
|
- name: Linux build desktop
|
||||||
id: linux_desktop_build
|
id: linux_desktop_build
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
scripts/desktop/build-lib-linux.sh
|
scripts/desktop/build-lib-linux.sh
|
||||||
@@ -168,10 +200,10 @@ jobs:
|
|||||||
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
|
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
|
||||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Linux make AppImage
|
- name: Linux make AppImage
|
||||||
id: linux_appimage_build
|
id: linux_appimage_build
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
scripts/desktop/make-appimage-linux.sh
|
scripts/desktop/make-appimage-linux.sh
|
||||||
@@ -181,7 +213,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Mac build desktop
|
- name: Mac build desktop
|
||||||
id: mac_desktop_build
|
id: mac_desktop_build
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
|
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
|
||||||
@@ -194,7 +226,7 @@ jobs:
|
|||||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Linux upload desktop package to release
|
- name: Linux upload desktop package to release
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -203,7 +235,7 @@ jobs:
|
|||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Linux update desktop package hash
|
- name: Linux update desktop package hash
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -213,7 +245,7 @@ jobs:
|
|||||||
${{ steps.linux_desktop_build.outputs.package_hash }}
|
${{ steps.linux_desktop_build.outputs.package_hash }}
|
||||||
|
|
||||||
- name: Linux upload AppImage to release
|
- name: Linux upload AppImage to release
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -222,7 +254,7 @@ jobs:
|
|||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Linux update AppImage hash
|
- name: Linux update AppImage hash
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04'
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -232,7 +264,7 @@ jobs:
|
|||||||
${{ steps.linux_appimage_build.outputs.appimage_hash }}
|
${{ steps.linux_appimage_build.outputs.appimage_hash }}
|
||||||
|
|
||||||
- name: Mac upload desktop package to release
|
- name: Mac upload desktop package to release
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -241,7 +273,7 @@ jobs:
|
|||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Mac update desktop package hash
|
- name: Mac update desktop package hash
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'macos-latest'
|
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -250,9 +282,18 @@ jobs:
|
|||||||
body: |
|
body: |
|
||||||
${{ steps.mac_desktop_build.outputs.package_hash }}
|
${{ steps.mac_desktop_build.outputs.package_hash }}
|
||||||
|
|
||||||
|
- name: Cache unix build
|
||||||
|
uses: actions/cache/save@v3
|
||||||
|
if: matrix.os != 'windows-latest'
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ matrix.cache_path }}
|
||||||
|
dist-newstyle
|
||||||
|
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- name: Unix test
|
- name: Unix test
|
||||||
if: matrix.os != 'windows-latest'
|
if: matrix.os != 'windows-latest'
|
||||||
timeout-minutes: 30
|
timeout-minutes: 40
|
||||||
shell: bash
|
shell: bash
|
||||||
run: cabal test --test-show-details=direct
|
run: cabal test --test-show-details=direct
|
||||||
|
|
||||||
@@ -281,7 +322,7 @@ jobs:
|
|||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
shell: msys2 {0}
|
shell: msys2 {0}
|
||||||
run: |
|
run: |
|
||||||
export PATH=$PATH:/c/ghcup/bin
|
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
|
||||||
scripts/desktop/prepare-openssl-windows.sh
|
scripts/desktop/prepare-openssl-windows.sh
|
||||||
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
|
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
|
||||||
rm cabal.project.local 2>/dev/null || true
|
rm cabal.project.local 2>/dev/null || true
|
||||||
@@ -323,14 +364,14 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||||
shell: msys2 {0}
|
shell: msys2 {0}
|
||||||
run: |
|
run: |
|
||||||
export PATH=$PATH:/c/ghcup/bin
|
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
|
||||||
scripts/desktop/build-lib-windows.sh
|
scripts/desktop/build-lib-windows.sh
|
||||||
cd apps/multiplatform
|
cd apps/multiplatform
|
||||||
./gradlew packageMsi
|
./gradlew packageMsi
|
||||||
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
|
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
|
||||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Windows upload desktop package to release
|
- name: Windows upload desktop package to release
|
||||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
@@ -350,4 +391,13 @@ jobs:
|
|||||||
body: |
|
body: |
|
||||||
${{ steps.windows_desktop_build.outputs.package_hash }}
|
${{ steps.windows_desktop_build.outputs.package_hash }}
|
||||||
|
|
||||||
|
- name: Cache windows build
|
||||||
|
uses: actions/cache/save@v3
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ matrix.cache_path }}
|
||||||
|
dist-newstyle
|
||||||
|
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
# Windows /
|
# Windows /
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ on:
|
|||||||
pull_request_target:
|
pull_request_target:
|
||||||
types: [opened, closed, synchronize]
|
types: [opened, closed, synchronize]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
statuses: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
CLAssistant:
|
CLAssistant:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: "CLA Assistant"
|
- name: "CLA Assistant"
|
||||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request'
|
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||||
# Beta Release
|
# Beta Release
|
||||||
uses: cla-assistant/github-action@v2.1.3-beta
|
uses: cla-assistant/github-action@v2.3.0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||||
@@ -33,4 +39,4 @@ jobs:
|
|||||||
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
|
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
|
||||||
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
|
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
|
||||||
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
|
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
|
||||||
#use-dco-flag: true - If you are using DCO instead of CLA
|
#use-dco-flag: true - If you are using DCO instead of CLA
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- stable
|
|
||||||
paths:
|
paths:
|
||||||
- website/**
|
- website/**
|
||||||
- images/**
|
- images/**
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ website/translations.json
|
|||||||
website/src/img/images/
|
website/src/img/images/
|
||||||
website/src/images/
|
website/src/images/
|
||||||
website/src/js/lottie.min.js
|
website/src/js/lottie.min.js
|
||||||
|
website/src/privacy.md
|
||||||
# Generated files
|
# Generated files
|
||||||
website/package/generated*
|
website/package/generated*
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,41 @@
|
|||||||
FROM ubuntu:focal AS build
|
ARG TAG=22.04
|
||||||
|
|
||||||
# Install curl and simplex-chat-related dependencies
|
FROM ubuntu:${TAG} AS build
|
||||||
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
|
|
||||||
|
### Build stage
|
||||||
|
|
||||||
|
# Install curl and git and simplex-chat dependencies
|
||||||
|
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev llvm-12 llvm-12-dev libnuma-dev libssl-dev
|
||||||
|
|
||||||
|
# Specify bootstrap Haskell versions
|
||||||
|
ENV BOOTSTRAP_HASKELL_GHC_VERSION=9.6.3
|
||||||
|
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=3.10.1.0
|
||||||
|
|
||||||
# Install ghcup
|
# Install ghcup
|
||||||
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
|
||||||
chmod +x /usr/bin/ghcup
|
|
||||||
|
|
||||||
# Install ghc
|
|
||||||
RUN ghcup install ghc 9.6.3
|
|
||||||
# Install cabal
|
|
||||||
RUN ghcup install cabal 3.10.1.0
|
|
||||||
# Set both as default
|
|
||||||
RUN ghcup set ghc 9.6.3 && \
|
|
||||||
ghcup set cabal 3.10.1.0
|
|
||||||
|
|
||||||
COPY . /project
|
|
||||||
WORKDIR /project
|
|
||||||
|
|
||||||
# Adjust PATH
|
# Adjust PATH
|
||||||
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||||
|
|
||||||
|
# Set both as default
|
||||||
|
RUN ghcup set ghc "${BOOTSTRAP_HASKELL_GHC_VERSION}" && \
|
||||||
|
ghcup set cabal "${BOOTSTRAP_HASKELL_CABAL_VERSION}"
|
||||||
|
|
||||||
|
COPY . /project
|
||||||
|
WORKDIR /project
|
||||||
|
|
||||||
# Adjust build
|
# Adjust build
|
||||||
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
||||||
|
|
||||||
# Compile simplex-chat
|
# Compile simplex-chat
|
||||||
RUN cabal update
|
RUN cabal update
|
||||||
RUN cabal install
|
RUN cabal build exe:simplex-chat
|
||||||
|
|
||||||
|
# Strip the binary from debug symbols to reduce size
|
||||||
|
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
|
||||||
|
mv "$bin" ./ && \
|
||||||
|
strip ./simplex-chat
|
||||||
|
|
||||||
|
# Copy compiled app from build stage
|
||||||
FROM scratch AS export-stage
|
FROM scratch AS export-stage
|
||||||
COPY --from=build /root/.cabal/bin/simplex-chat /
|
COPY --from=build /project/simplex-chat /
|
||||||
|
|||||||
@@ -1,134 +1,175 @@
|
|||||||
# SimpleX Chat Terms & Privacy Policy
|
---
|
||||||
|
layout: layouts/privacy.html
|
||||||
|
permalink: /privacy/index.html
|
||||||
|
---
|
||||||
|
|
||||||
SimpleX Chat is the first communication platform that has no user profile IDs of any kind, not even random numbers. Not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we cannot observe your connections graph.
|
# SimpleX Chat Privacy Policy and Conditions of Use
|
||||||
|
|
||||||
If you believe that some of the clauses in this document are not aligned with our mission or principles, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
|
SimpleX Chat is the first communication network based on a new protocol stack that builds on the same ideas of complete openness and decentralization as email and web, with the focus on providing security and privacy of communications, and without compromising on usability.
|
||||||
|
|
||||||
|
SimpleX Chat communication protocol is the first protocol that has no user profile IDs of any kind, not even random numbers, cryptographic keys or hashes that identify the users. SimpleX Chat apps allow their users to send messages and files via relay server infrastructure. Relay server owners and providers do not have any access to your messages, thanks to double-ratchet end-to-end encryption algorithm (also known as Signal algorithm - do not confuse with Signal protocols or platform) and additional encryption layers, and they also have no access to your profile and contacts - as they do not provide any user accounts.
|
||||||
|
|
||||||
|
Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)).
|
||||||
|
|
||||||
|
If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
|
||||||
|
|
||||||
## Privacy Policy
|
## Privacy Policy
|
||||||
|
|
||||||
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and encryption to provide secure [end-to-end encrypted](./docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the servers via [man-in-the-middle attack](./docs/GLOSSARY.md#man-in-the-middle-attack).
|
SimpleX Chat Ltd uses the best industry practices for security and encryption to provide client and server software for secure [end-to-end encrypted](/docs/GLOSSARY.md#end-to-end-encryption) messaging via private connections. This encryption cannot be compromised by the relays servers, even if they are modified or compromised, via [man-in-the-middle attack](/docs/GLOSSARY.md#man-in-the-middle-attack), unlike most other communication platforms, services and networks.
|
||||||
|
|
||||||
SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol allowing to establish private connections without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
|
SimpleX Chat software is built on top of SimpleX messaging and application protocols, based on a new message routing protocol allowing to establish private connections without having any kind of addresses or other identifiers assigned to its users - it does not use emails, phone numbers, usernames, identity keys or any other user profile identifiers to pass messages between the user applications.
|
||||||
|
|
||||||
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.0 – see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
SimpleX Chat software is similar in its design approach to email clients and browsers - it allows you to have full control of your data and freely choose the relay server providers, in the same way you choose which website or email provider to use, or use your own relay servers, simply by changing the configuration of the client software. The only current restriction to that is Apple push notifications - at the moment they can only be delivered via the preset servers that we operate, as explained below. We are exploring the solutions to deliver push notifications to iOS devices via other providers or users' own servers.
|
||||||
|
|
||||||
### Information you provide
|
While SimpleX Chat Ltd is not a communication service provider, and provide public preset relays "as is", as experimental, without any guarantees of availability or data retention, we are committed to maintain a high level of availability, reliability and security of these preset relays. We will be adding alternative preset infrastructure providers to the software in the future, and you will continue to be able to use any other providers or your own servers.
|
||||||
|
|
||||||
|
We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
|
||||||
|
|
||||||
|
SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 – see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||||
|
|
||||||
|
### Your information
|
||||||
|
|
||||||
#### User profiles
|
#### User profiles
|
||||||
|
|
||||||
We do not store user profiles. The profile you create in the app is local to your device.
|
Servers used by SimpleX Chat apps do not create, store or identify user profiles. The profiles you can create in the app are local to your device, and can be removed at any time via the app.
|
||||||
|
|
||||||
When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
|
When you create the local profile, no records are created on any of the relay servers, and infrastructure providers, whether SimpleX Chat Ltd or any other, have no access to any part of your information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all your data and the private connections you created with other software users.
|
||||||
|
|
||||||
|
You can transfer the profile to another device by creating a backup of the app data and restoring it on the new device, but you cannot use more than one device with the copy of the same profile at the same time - it will disrupt any active conversations on either or both devices, as a security property of end-to-end encryption.
|
||||||
|
|
||||||
#### Messages and Files
|
#### Messages and Files
|
||||||
|
|
||||||
SimpleX Chat cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 256kb, 1mb or 8mb via all or some of the configured file servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](./docs/GLOSSARY.md#key-exchange) happens out-of-band.
|
SimpleX relay servers cannot decrypt or otherwise access the content or even the size of your messages and files you send or receive. Each message is padded to a fixed size of 16kb. Each file is sent in chunks of 64kb, 256kb, 1mb or 8mb via all or some of the configured file relay servers. Both messages and files are sent end-to-end encrypted, and the servers do not have technical means to compromise this encryption, because part of the [key exchange](/docs/GLOSSARY.md#key-exchange) happens out-of-band.
|
||||||
|
|
||||||
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline SimpleX Chat temporarily stores end-to-end encrypted messages on the messaging (SMP) servers that are preset in the app or chosen by the users.
|
Your message history is stored only on your own device and the devices of your contacts. While the recipients' devices are offline, messaging relay servers temporarily store end-to-end encrypted messages – you can configure which relay servers are used to receive the messages from the new contacts, and you can manually change them for the existing contacts too.
|
||||||
|
|
||||||
The messages are permanently removed from the preset servers as soon as they are delivered. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
|
You do not have control over which servers are used to send messages to your contacts - they are chosen by them. To send messages your client needs to connect to these servers, therefore the servers chosen by your contacts can observe your IP address. You can use VPN or some overlay network (e.g., Tor) to hide your IP address from the servers chosen by your contacts. In the near future we will add the layer in the messaging protocol that will route sent message via the relays chosen by you as well.
|
||||||
|
|
||||||
The files are stored on file (XFTP) servers for the time configured in the file servers you use (48 hours for preset file servers).
|
The messages are permanently removed from the used relay servers as soon as they are delivered, as long as these servers used unmodified published code. Undelivered messages are deleted after the time that is configured in the messaging servers you use (21 days for preset messaging servers).
|
||||||
|
|
||||||
If a messaging or file servers are restarted, the encrypted message or the record of the file can be stored in a backup file until it is overwritten by the next restart (usually within 1 week).
|
The files are stored on file relay servers for the time configured in the relay servers you use (48 hours for preset file servers).
|
||||||
|
|
||||||
|
If a messaging servers are restarted, the encrypted message can be stored in a backup file until it is overwritten by the next restart (usually within 1 week for preset relay servers).
|
||||||
|
|
||||||
|
As this software is fully open-source and provided under AGPLv3 license, all infrastructure providers and owners, and the developers of the client and server applications who use the SimpleX Chat source code, are required to publish any changes to this software under the same AGPLv3 license - including any modifications to the provided servers.
|
||||||
|
|
||||||
|
In addition to the AGPLv3 license terms, SimpleX Chat Ltd is committed to the software users that the preset relays that we provide via the apps will always be compiled from the [published open-source code](https://github.com/simplex-chat/simplexmq), without any modifications.
|
||||||
|
|
||||||
#### Connections with other users
|
#### Connections with other users
|
||||||
|
|
||||||
When you create a connection with another user, two messaging queues (you can think about them as about mailboxes) are created on chosen messaging servers, that can be the preset servers or the servers that you configured in the app, in case it allows such configuration. SimpleX uses separate queues for direct and response messages, that the client applications prefer to create on two different servers, in case you have more than one server configured in the app, which is the default.
|
When you create a connection with another user, two messaging queues (you can think about them as mailboxes) are created on messaging relay servers (chosen by you and your contact each), that can be the preset servers or the servers that you and your contact configured in the app. SimpleX messaging protocol uses separate queues for direct and response messages, and the apps prefer to create these queues on two different relay servers for increased privacy, in case you have more than one relay server configured in the app, which is the default.
|
||||||
|
|
||||||
At the time of updating this document all our client applications allow configuring the servers. Our servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
|
SimpleX relay servers do not store information about which queues are linked to your profile on the device, and they do not collect any information that would allow infrastructure owners and providers to establish that these queues are related to your device or your profile - the access to each queue is authorized by two anonymous unique cryptographic keys, different for each queue, and separate for sender and recipient of the messages.
|
||||||
|
|
||||||
|
#### Connection links privacy
|
||||||
|
|
||||||
|
When you create a connection with another user, the app generates a link/QR code that can be shared with the user to establish the connection via any channel (email, any other messenger, or a video call). This link is safe to share via insecure channels, as long as you can identify the recipient and also trust that this channel did not replace this link (to mitigate the latter risk you can validate the security code via the app).
|
||||||
|
|
||||||
|
While the connection "links" contain SimpleX Chat Ltd domain name `simplex.chat`, this site is never accessed by the app, and is only used for these purposes:
|
||||||
|
- to direct the new users to the app download instructions,
|
||||||
|
- to show connection QR code that can be scanned via the app,
|
||||||
|
- to "namespace" these links,
|
||||||
|
- to open links directly in the installed app when it is clicked outside of the app.
|
||||||
|
|
||||||
|
You can always safely replace the initial part of the link `https://simplex.chat/` either with `simplex:/` (which is a URI scheme provisionally registered with IANA) or with any other domain name where you can self-host the app download instructions and show the connection QR code (but in case it is your domain, it will not open in the app). Also, while the page renders QR code, all the information needed to render it is only available to the browser, as the part of the "link" after `#` symbol is not sent to the website server.
|
||||||
|
|
||||||
#### iOS Push Notifications
|
#### iOS Push Notifications
|
||||||
|
|
||||||
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
|
When you choose to use instant push notifications in SimpleX iOS app, because the design of push notifications requires storing the device token on notification server, the notifications server can observe how many messaging queues your device has notifications enabled for, and approximately how many messages are sent to each queue.
|
||||||
|
|
||||||
Notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who, or even how many contacts, send messages to you, as notifications are delivered to your device end-to-end encrypted by the messaging servers.
|
Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers.
|
||||||
|
|
||||||
It also does not allow to see message content or sizes, as the actual messages are not sent via the notification server, only the fact that the message is available and where it can be received from (the latter information is encrypted, so that the notification server cannot observe it). You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off).
|
You can read more about the design of iOS push notifications [here](./blog/20220404-simplex-chat-instant-notifications.md#our-ios-approach-has-one-trade-off).
|
||||||
|
|
||||||
#### Another information stored on the servers
|
#### Another information stored on the servers
|
||||||
|
|
||||||
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
|
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat design limits this additional technical information to the minimum required to operate the software and servers. To prevent server overloading or attacks, the servers can temporarily store data that can link to particular users or devices, including IP addresses, geographic location, or information related to the transport sessions. This information is not stored for the absolute majority of the app users, even for those who use the servers very actively.
|
||||||
|
|
||||||
#### SimpleX Directory Service
|
#### SimpleX Directory
|
||||||
|
|
||||||
[SimpleX directory service](./docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the group. You can connect to SimpleX Directory Service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
|
[SimpleX Directory](/docs/DIRECTORY.md) stores: your search requests, the messages and the members profiles in the registered groups. You can connect to SimpleX Directory via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion).
|
||||||
|
|
||||||
#### User Support.
|
#### User Support
|
||||||
|
|
||||||
If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion), when it is possible.
|
If you contact SimpleX Chat Ltd, any personal data you share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support [via chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) when it is possible, and avoid sharing any personal information.
|
||||||
|
|
||||||
### Information we may share
|
### Information we may share
|
||||||
|
|
||||||
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
|
SimpleX Chat Ltd operates preset relay servers using third parties. While we do not have access and cannot share any user data, these third parties may access the encrypted user messages (but NOT the actual unencrypted message content or size) as it is stored or transmitted via our servers. Hosting providers can also store IP addresses and other transport information as part of their logs.
|
||||||
|
|
||||||
We use a third party for email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according to their privacy policies and terms of service.
|
We use a third party for email services - if you ask for support via email, your and SimpleX Chat Ltd email providers may access these emails according to their privacy policies and terms. When the request is sensitive, we recommend contacting us via SimpleX Chat or using encrypted email using PGP key published at [openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat).
|
||||||
|
|
||||||
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
|
The cases when SimpleX Chat Ltd may share the data temporarily stored on the servers:
|
||||||
|
|
||||||
- To meet any applicable law, regulation, legal process or enforceable governmental request.
|
- To meet any applicable law, or enforceable governmental request or court order.
|
||||||
- To enforce applicable Terms, including investigation of potential violations.
|
- To enforce applicable terms, including investigation of potential violations.
|
||||||
- To detect, prevent, or otherwise address fraud, security, or technical issues.
|
- To detect, prevent, or otherwise address fraud, security, or technical issues.
|
||||||
- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law.
|
- To protect against harm to the rights, property, or safety of software users, SimpleX Chat Ltd, or the public as required or permitted by law.
|
||||||
|
|
||||||
At the time of updating this document, we have never provided or have been requested the access to our servers or any information from our servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process.
|
At the time of updating this document, we have never provided or have been requested the access to the preset relay servers or any information from the servers by any third parties. If we are ever requested to provide such access or information, we will follow the due legal process to limit any information shared with the third parties to the minimally required by law.
|
||||||
|
|
||||||
|
We will publish information we are legally allowed to share about such requests in the [Transparency reports](./docs/TRANSPARENCY.md).
|
||||||
|
|
||||||
### Updates
|
### Updates
|
||||||
|
|
||||||
We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
|
We will update this Privacy Policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our software applications and preset relays infrastructure confirms your acceptance of our updated Privacy Policy.
|
||||||
|
|
||||||
Please also read our Terms of Service below.
|
Please also read our Conditions of Use of Software and Infrastructure below.
|
||||||
|
|
||||||
If you have questions about our Privacy Policy please contact us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
|
If you have questions about our Privacy Policy please contact us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion).
|
||||||
|
|
||||||
## Terms of Service
|
## Conditions of Use of Software and Infrastructure
|
||||||
|
|
||||||
You accept our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
|
You accept the Conditions of Use of Software and Infrastructure ("Conditions") by installing or using any of our software or using any of our server infrastructure (collectively referred to as "Applications"), whether preset in the software or not.
|
||||||
|
|
||||||
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
|
**Minimal age**. You must be at least 13 years old to use our Applications. The minimum age to use our Applications without parental approval may be higher in your country.
|
||||||
|
|
||||||
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
**Infrastructure**. Our Infrastructure includes preset messaging and file relay servers, and iOS push notification servers provided by SimpleX Chat Ltd for public use. Our infrastructure does not have any modifications from the [published open-source code](https://github.com/simplex-chat/simplexmq) available under AGPLv3 license. Any infrastructure provider, whether commercial or not, is required by the Affero clause (named after Affero Inc. company that pioneered the community-based Q&A sites in early 2000s) to publish any modifications under the same license. The statements in relation to Infrastructure and relay servers anywhere in this document assume no modifications to the published code, even in the cases when it is not explicitly stated.
|
||||||
|
|
||||||
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per user - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
|
**Client applications**. Our client application Software (referred to as "app" or "apps") also has no modifications compared with published open-source code, and any developers of the alternative client apps based on our code are required to publish any modifications under the same AGPLv3 license. Client applications should not include any tracking or analytics code, and do not share any information with SimpleX Chat Ltd or any other third parties. If you ever discover any tracking or analytics code, please report it to us, so we can remove it.
|
||||||
|
|
||||||
**Operating our services**. For the purpose of operating our Services, you agree that your end-to-end encrypted messages are transferred via our servers in the United Kingdom, the United States and other countries where we have or use facilities and service providers or partners.
|
**Accessing the infrastructure**. For the efficiency of the network access, the client Software by default accesses all queues your app creates on any relay server within one user profile via the same network (TCP/IP) connection. At the cost of additional traffic this configuration can be changed to use different transport session for each connection. Relay servers do not collect information about which queues were created or accessed via the same connection, so the relay servers cannot establish which queues belong to the same user profile. Whoever might observe your network traffic would know which relay servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or ciphertext in common, even inside TLS encryption layer. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
|
||||||
|
|
||||||
**Software**. You agree to downloading and installing updates to our Services when they are available; they would only be automatic if you configure your devices in this way.
|
**Privacy of user data**. Servers do not retain any data we transmit for any longer than necessary to deliver the messages between apps. SimpleX Chat Ltd collects aggregate statistics across all its servers, as supported by published code and can be enabled by any infrastructure provider, but not any statistics per-user, or per geographic location, or per IP address, or per transport session. We do not have information about how many people use SimpleX Chat applications, we only know an approximate number of app installations and the aggregate traffic through the preset servers. In any case, we do not and will not sell or in any way monetize user data. Our future business model assumes charging for some optional Software features instead, in a transparent and fair way.
|
||||||
|
|
||||||
**Traffic and device costs**. You are solely responsible for the traffic and device costs on which you use our Services, and any associated taxes.
|
**Operating our Infrastructure**. For the purpose of using our Software, if you continue using preset servers, you agree that your end-to-end encrypted messages are transferred via the preset servers in any countries where we have or use facilities and service providers or partners. The information about geographic location of the servers will be made available in the apps in the near future.
|
||||||
|
|
||||||
**Legal and acceptable usage**. You agree to use our Services only for legal and acceptable purposes. You will not use (or assist others in using) our Services in ways that: 1) violate or infringe the rights of SimpleX Chat, our users, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam.
|
**Software**. You agree to downloading and installing updates to our Applications when they are available; they would only be automatic if you configure your devices in this way.
|
||||||
|
|
||||||
**Damage to SimpleX Chat**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Services in unauthorized manners, or in ways that harm SimpleX Chat, our Services, or systems. For example, you must not 1) access our Services or systems without authorization, other than by using the apps; 2) disrupt the integrity or performance of our Services; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Services.
|
**Traffic and device costs**. You are solely responsible for the traffic and device costs that you incur while using our Applications, and any associated taxes.
|
||||||
|
|
||||||
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
|
**Legal usage**. You agree to use our Applications only for legal purposes. You will not use (or assist others in using) our Applications in ways that: 1) violate or infringe the rights of Software users, SimpleX Chat Ltd, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal communications, e.g. spam. While we cannot access content or identify messages or groups, in some cases the links to the illegal communications available via our Applications can be shared publicly on social media or websites. We reserve the right to remove such links from the preset servers and disrupt the conversations that send illegal content via our servers, whether they were reported by the users or discovered by our team.
|
||||||
|
|
||||||
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the application you use. Legacy databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app. In this case, if you make a backup of the app data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the beta version of desktop app currently stores the database passphrase in the configuration file in plaintext, so you may need to remove passphrase from the device via the app configuration.
|
**Damage to SimpleX Chat Ltd**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Applications in unauthorized manners, or in ways that harm Software users, SimpleX Chat Ltd, our Infrastructure, or any other systems. For example, you must not 1) access our Infrastructure or systems without authorization, in any way other than by using the Software; 2) disrupt the integrity or performance of our Infrastructure; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Infrastructure. This does not prohibit you from providing your own Infrastructure to others, whether free or for a fee, as long as you do not violate these Conditions and AGPLv3 license, including the requirement to publish any modifications of the relay server software.
|
||||||
|
|
||||||
**Storing the files on the device**. The files are stored on your device unencrypted. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access the files.
|
**Keeping your data secure**. SimpleX Chat is the first communication software that aims to be 100% private by design - server software neither has the ability to access your messages, nor it has information about who you communicate with. That means that you are solely responsible for keeping your device, your user profile and any data safe and secure. If you lose your phone or remove the Software from the device, you will not be able to recover the lost data, unless you made a back up. To protect the data you need to make regular backups, as using old backups may disrupt your communication with some of the contacts.
|
||||||
|
|
||||||
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
|
**Storing the messages on the device**. The messages are stored in the encrypted database on your device. Whether and how database passphrase is stored is determined by the configuration of the Software you use. The databases created prior to 2023 or in CLI (terminal) app may remain unencrypted, and it will be indicated in the app interface. In this case, if you make a backup of the data and store it unencrypted, the backup provider may be able to access the messages. Please note, that the desktop apps can be configured to store the database passphrase in the configuration file in plaintext, and unless you set the passphrase when first running the app, a random passphrase will be used and stored on the device. You can remove it from the device via the app settings.
|
||||||
|
|
||||||
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
|
**Storing the files on the device**. The files currently sent and received in the apps by default (except CLI app) are stored on your device encrypted using unique keys, different for each file, that are stored in the database. Once the message that the file was attached to is removed, even if the copy of the encrypted file is retained, it should be impossible to recover the key allowing to decrypt the file. This local file encryption may affect app performance, and it can be disabled via the app settings. This change will only affect the new files. If you later re-enable the encryption, it will also affect only the new files. If you make a backup of the app data and store it unencrypted, the backup provider will be able to access any unencrypted files. In any case, irrespective of the storage setting, the files are always sent by all apps end-to-end encrypted.
|
||||||
|
|
||||||
**Your Rights**. You own the messages and the information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the app.
|
**No Access to Emergency Services**. Our Applications do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
|
||||||
|
|
||||||
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
|
**Third-party services**. Our Applications may allow you to access, use, or interact with our or third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
|
||||||
|
|
||||||
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
|
**Your Rights**. You own the messages and the information you transmit through our Applications. Your recipients are able to retain the messages they receive from you; there is no technical ability to delete data from their devices. While there are various app features that allow deleting messages from the recipients' devices, such as _disappearing messages_ and _full message deletion_, their functioning on your recipients' devices cannot be guaranteed or enforced, as the device may be offline or have a modified version of the Software. At the same time, repudiation property of the end-to-end encryption algorithm allows you to plausibly deny having sent the message, like you can deny what you said in a private face-to-face conversation, as the recipient cannot provide any proof to the third parties, by design.
|
||||||
|
|
||||||
**Disclaimers**. YOU USE OUR SERVICES AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR SERVICES ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR SERVICES WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR SERVICES WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR SERVICES. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
|
**License**. SimpleX Chat Ltd grants you a limited, revocable, non-exclusive, and non-transferable license to use our Applications in accordance with these Conditions. The source-code of Applications is available and can be used under [AGPL v3 license](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE).
|
||||||
|
|
||||||
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR TERMS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
**SimpleX Chat Ltd Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Applications. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
|
||||||
|
|
||||||
**Availability**. Our Services may be interrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Services, including certain features and the support for certain devices and platforms, at any time.
|
**Disclaimers**. YOU USE OUR APPLICATIONS AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR APPLICATIONS ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX CHAT LTD DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR APPLICATIONS WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR APPLICATIONS WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR APPLICATIONS. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
|
||||||
|
|
||||||
**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Terms, us, or our Services in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Terms, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat and you, without regard to conflict of law provisions.
|
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR CONDITIONS, US, OR OUR APPLICATIONS WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR CONDITIONS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||||
|
|
||||||
**Changes to the terms**. SimpleX Chat may update the Terms from time to time. Your continued use of our Services confirms your acceptance of our updated Terms and supersedes any prior Terms. You will comply with all applicable export control and trade sanctions laws. Our Terms cover the entire agreement between you and SimpleX Chat regarding our Services. If you do not agree with our Terms, you should stop using our Services.
|
**Availability**. Our Applications may be disrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Applications, including certain features and the support for certain devices and platforms, at any time.
|
||||||
|
|
||||||
**Enforcing the terms**. If we fail to enforce any of our Terms, that does not mean we waive the right to enforce them. If any provision of the Terms is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Terms and shall not affect the enforceability of the remaining provisions. Our Services are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Services in any country. If you have specific questions about these Terms, please contact us at chat@simplex.chat.
|
**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Conditions, us, or our Applications in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Conditions, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat Ltd and you, without regard to conflict of law provisions.
|
||||||
|
|
||||||
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
|
**Changes to the conditions**. SimpleX Chat Ltd may update the Conditions from time to time. Your continued use of our Applications confirms your acceptance of our updated Conditions and supersedes any prior Conditions. You will comply with all applicable export control and trade sanctions laws. Our Conditions cover the entire agreement between you and SimpleX Chat Ltd regarding our Applications. If you do not agree with our Conditions, you should stop using our Applications.
|
||||||
|
|
||||||
Updated August 17, 2023
|
**Enforcing the conditions**. If we fail to enforce any of our Conditions, that does not mean we waive the right to enforce them. If any provision of the Conditions is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Conditions and shall not affect the enforceability of the remaining provisions. Our Applications are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Applications in any country. If you have specific questions about these Conditions, please contact us at chat@simplex.chat.
|
||||||
|
|
||||||
|
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
|
||||||
|
|
||||||
|
Updated April 24, 2024
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
[](https://www.reddit.com/r/SimpleXChat)
|
[](https://www.reddit.com/r/SimpleXChat)
|
||||||
<a rel="me" href="https://mastodon.social/@simplex"></a>
|
<a rel="me" href="https://mastodon.social/@simplex"></a>
|
||||||
|
|
||||||
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md), [PL](/docs/lang/pl/README.md) |
|
||||||
|
|
||||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||||
|
|
||||||
@@ -72,9 +72,9 @@ 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-Group-4](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2Fw2GlucRXtRVgYnbt_9ZP-kmt76DekxxS%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0tJhTyMGUxznwmjb7aT24P1I1Wry_iURTuhOFlMb1Eo%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22WoPxjFqGEDlVazECOSi2dg%3D%3D%22%7D)
|
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-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) for developers who build on SimpleX platform:
|
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:
|
||||||
|
|
||||||
- chat bots and automations
|
- chat bots and automations
|
||||||
- integrations with other apps
|
- integrations with other apps
|
||||||
@@ -127,6 +127,7 @@ Join our translators to help SimpleX grow!
|
|||||||
|🇫🇮 fi|Suomi | |[](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|
|🇫🇮 fi|Suomi | |[](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|
||||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||||
|🇮🇱 he|עִברִית | |[](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-|||
|
|🇮🇱 he|עִברִית | |[](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-|||
|
||||||
|
|🇭🇺 hu|Magyar | |[](https://hosted.weblate.org/projects/simplex-chat/android/hu/)<br>-|||
|
||||||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||||
|🇯🇵 ja|日本語 | |[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|
|🇯🇵 ja|日本語 | |[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|
||||||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||||
@@ -134,6 +135,7 @@ Join our translators to help SimpleX grow!
|
|||||||
|🇧🇷 pt-BR|Português||[](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|
|🇧🇷 pt-BR|Português||[](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|
||||||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|
||||||
|
|🇹🇷 tr|Türkçe | |[](https://hosted.weblate.org/projects/simplex-chat/android/tr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/tr/)|||
|
||||||
|🇺🇦 uk|Українська| |[](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|
|🇺🇦 uk|Українська| |[](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|
||||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||||
|
|
||||||
@@ -156,20 +158,19 @@ We are prioritizing users privacy and security - it would be impossible without
|
|||||||
|
|
||||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||||
|
|
||||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||||
|
|
||||||
It is possible to donate via:
|
It is possible to donate via:
|
||||||
|
|
||||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
|
||||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad
|
||||||
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||||
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
|
||||||
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||||
- USDT:
|
- USDT:
|
||||||
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
|
||||||
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
|
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
|
||||||
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
- please ask if you want to donate any other coins.
|
||||||
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
|
||||||
|
|
||||||
Thank you,
|
Thank you,
|
||||||
|
|
||||||
@@ -232,6 +233,16 @@ You can use SimpleX with your own servers and still communicate with people usin
|
|||||||
|
|
||||||
Recent and important updates:
|
Recent and important updates:
|
||||||
|
|
||||||
|
[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)
|
||||||
|
|
||||||
|
[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)
|
||||||
|
|
||||||
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
|
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.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).
|
[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).
|
||||||
@@ -297,7 +308,7 @@ What is already implemented:
|
|||||||
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
11. 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. Manual messaging queue rotations to move conversation to another SMP relay.
|
12. Manual messaging queue rotations to move conversation to another SMP relay.
|
||||||
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
||||||
14. Local files encryption, except videos (to be added later).
|
14. Local files encryption.
|
||||||
|
|
||||||
We plan to add:
|
We plan to add:
|
||||||
|
|
||||||
@@ -369,12 +380,14 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
|||||||
- ✅ Desktop client.
|
- ✅ Desktop client.
|
||||||
- ✅ Encryption of local files stored in the app.
|
- ✅ Encryption of local files stored in the app.
|
||||||
- ✅ Using mobile profiles from the desktop app.
|
- ✅ Using mobile profiles from the desktop app.
|
||||||
|
- ✅ Private notes.
|
||||||
|
- ✅ Improve sending videos (including encryption of locally stored videos).
|
||||||
|
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
|
||||||
|
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||||
|
- 🏗 Improve stability and reduce battery usage.
|
||||||
- 🏗 Improve experience for the new users.
|
- 🏗 Improve experience for the new users.
|
||||||
- 🏗 Post-quantum resistant key exchange in double ratchet protocol.
|
|
||||||
- 🏗 Large groups, communities and public channels.
|
- 🏗 Large groups, communities and public channels.
|
||||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
|
||||||
- Privacy & security slider - a simple way to set all settings at once.
|
- Privacy & security slider - a simple way to set all settings at once.
|
||||||
- Improve sending videos (including encryption of locally stored videos).
|
|
||||||
- SMP queue redundancy and rotation (manual is supported).
|
- SMP queue redundancy and rotation (manual is supported).
|
||||||
- Include optional message into connection request sent via contact address.
|
- Include optional message into connection request sent via contact address.
|
||||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||||
@@ -402,13 +415,13 @@ We have never provided or have been requested access to our servers or any infor
|
|||||||
|
|
||||||
We do not log IP addresses of the users and we do not perform any traffic correlation on our servers. If transport level security is critical you must use Tor or some other similar network to access messaging servers. We will be improving the client applications to reduce the opportunities for traffic correlation.
|
We do not log IP addresses of the users and we do not perform any traffic correlation on our servers. If transport level security is critical you must use Tor or some other similar network to access messaging servers. We will be improving the client applications to reduce the opportunities for traffic correlation.
|
||||||
|
|
||||||
Please read more in [Terms & privacy policy](./PRIVACY.md).
|
Please read more in [Privacy Policy](./PRIVACY.md).
|
||||||
|
|
||||||
## Security contact
|
## Security contact
|
||||||
|
|
||||||
To report a security vulnerability, please send us email to chat@simplex.chat. We will coordinate the fix and disclosure. Please do NOT report security vulnerabilities via GitHub issues.
|
Please see our [Security Policy](./docs/SECURITY.md) on how to report security vulnerabilities to us. We will coordinate the fix and disclosure.
|
||||||
|
|
||||||
Please treat any findings of possible traffic correlation attacks allowing to correlate two different conversations to the same user, other than covered in [the threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#threat-model), as security vulnerabilities, and follow this disclosure process.
|
Please do NOT report security vulnerabilities via GitHub issues.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||||
application.registerForRemoteNotifications()
|
application.registerForRemoteNotifications()
|
||||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
if #available(iOS 17.0, *) { trackKeyboard() }
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
||||||
|
removePasscodesIfReinstalled()
|
||||||
|
prepareForLaunch()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,12 +40,17 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
ChatModel.shared.keyboardHeight = 0
|
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)")
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
|
let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token)
|
||||||
m.deviceToken = deviceToken
|
m.deviceToken = deviceToken
|
||||||
|
// savedToken is set in startChat, when it is started before this method is called
|
||||||
if m.savedToken != nil {
|
if m.savedToken != nil {
|
||||||
registerToken(token: deviceToken)
|
registerToken(token: deviceToken)
|
||||||
}
|
}
|
||||||
@@ -80,7 +89,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
|
} else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages {
|
||||||
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
|
logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages")
|
||||||
if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic {
|
if m.ntfEnablePeriodic && allowBackgroundRefresh() && BGManager.shared.lastRanLongAgo {
|
||||||
receiveMessages(completionHandler)
|
receiveMessages(completionHandler)
|
||||||
} else {
|
} else {
|
||||||
completionHandler(.noData)
|
completionHandler(.noData)
|
||||||
@@ -121,6 +130,23 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
BGManager.shared.receiveMessages(complete)
|
BGManager.shared.receiveMessages(complete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func removePasscodesIfReinstalled() {
|
||||||
|
// Check for the database existence, because app and self destruct passcodes
|
||||||
|
// will be saved and restored by iOS when a user deletes and re-installs the app.
|
||||||
|
// In this case the database and settings will be deleted, but the passcodes won't be.
|
||||||
|
// Deleting passcodes ensures that the user will not get stuck on "Opening app..." screen.
|
||||||
|
if (kcAppPassword.get() != nil || kcSelfDestructPassword.get() != nil) &&
|
||||||
|
!UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) && !hasDatabase() {
|
||||||
|
_ = kcAppPassword.remove()
|
||||||
|
_ = kcSelfDestructPassword.remove()
|
||||||
|
_ = kcDatabasePassword.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareForLaunch() {
|
||||||
|
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
static func keepScreenOn(_ on: Bool) {
|
static func keepScreenOn(_ on: Bool) {
|
||||||
UIApplication.shared.isIdleTimerDisabled = on
|
UIApplication.shared.isIdleTimerDisabled = on
|
||||||
}
|
}
|
||||||
@@ -128,13 +154,79 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
|
|
||||||
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
static var windowStatic: UIWindow?
|
||||||
var windowScene: UIWindowScene?
|
var windowScene: UIWindowScene?
|
||||||
|
|
||||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
|
UITableView.appearance().backgroundColor = .clear
|
||||||
guard let windowScene = scene as? UIWindowScene else { return }
|
guard let windowScene = scene as? UIWindowScene else { return }
|
||||||
self.windowScene = windowScene
|
self.windowScene = windowScene
|
||||||
window = windowScene.keyWindow
|
window = windowScene.keyWindow
|
||||||
window?.tintColor = UIColor(cgColor: getUIAccentColorDefault())
|
SceneDelegate.windowStatic = windowScene.keyWindow
|
||||||
window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault()
|
migrateAccentColorAndTheme()
|
||||||
|
ThemeManager.applyTheme(currentThemeDefault.get())
|
||||||
|
ThemeManager.adjustWindowStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func migrateAccentColorAndTheme() {
|
||||||
|
let defs = UserDefaults.standard
|
||||||
|
/// For checking migration
|
||||||
|
// themeOverridesDefault.set([])
|
||||||
|
// currentThemeDefault.set(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
// defs.set(0.5, forKey: DEFAULT_ACCENT_COLOR_RED)
|
||||||
|
// defs.set(0.3, forKey: DEFAULT_ACCENT_COLOR_GREEN)
|
||||||
|
// defs.set(0.8, forKey: DEFAULT_ACCENT_COLOR_BLUE)
|
||||||
|
|
||||||
|
let userInterfaceStyle = getUserInterfaceStyleDefault()
|
||||||
|
if defs.double(forKey: DEFAULT_ACCENT_COLOR_GREEN) == 0 && userInterfaceStyle == .unspecified {
|
||||||
|
// No migration needed or already migrated
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultAccentColor = Color(cgColor: CGColor(red: 0.000, green: 0.533, blue: 1.000, alpha: 1))
|
||||||
|
let accentColor = Color(cgColor: getUIAccentColorDefault())
|
||||||
|
if accentColor != defaultAccentColor {
|
||||||
|
let colors = ThemeColors(primary: accentColor.toReadableHex())
|
||||||
|
var overrides = themeOverridesDefault.get()
|
||||||
|
var themeIds = currentThemeIdsDefault.get()
|
||||||
|
switch userInterfaceStyle {
|
||||||
|
case .light:
|
||||||
|
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(light)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
|
||||||
|
case .dark:
|
||||||
|
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(dark)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.DARK.themeName)
|
||||||
|
case .unspecified:
|
||||||
|
let light = ThemeOverrides(base: DefaultTheme.LIGHT, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
let dark = ThemeOverrides(base: DefaultTheme.DARK, colors: colors, wallpaper: ThemeWallpaper(preset: PresetWallpaper.school.filename))
|
||||||
|
overrides.append(light)
|
||||||
|
overrides.append(dark)
|
||||||
|
themeOverridesDefault.set(overrides)
|
||||||
|
themeIds[DefaultTheme.LIGHT.themeName] = light.themeId
|
||||||
|
themeIds[DefaultTheme.DARK.themeName] = dark.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
@unknown default: ()
|
||||||
|
}
|
||||||
|
} else if userInterfaceStyle != .unspecified {
|
||||||
|
let themeName = switch userInterfaceStyle {
|
||||||
|
case .light: DefaultTheme.LIGHT.themeName
|
||||||
|
case .dark: DefaultTheme.DARK.themeName
|
||||||
|
default: DefaultTheme.SYSTEM_THEME_NAME
|
||||||
|
}
|
||||||
|
ThemeManager.applyTheme(themeName)
|
||||||
|
}
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_RED)
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_GREEN)
|
||||||
|
defs.removeObject(forKey: DEFAULT_ACCENT_COLOR_BLUE)
|
||||||
|
defs.removeObject(forKey: DEFAULT_USER_INTERFACE_STYLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_cats@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_flowers@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_hearts@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_kids@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_school@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 233 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@1x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "wallpaper_travel@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -14,11 +14,16 @@ struct ContentView: View {
|
|||||||
@ObservedObject var alertManager = AlertManager.shared
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
@ObservedObject var callController = CallController.shared
|
@ObservedObject var callController = CallController.shared
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@Binding var doAuthenticate: Bool
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var userAuthorized: Bool?
|
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||||
@Binding var canConnectCall: Bool
|
|
||||||
@Binding var lastSuccessfulUnlock: TimeInterval?
|
var contentAccessAuthenticationExtended: Bool
|
||||||
@Binding var showInitializationView: Bool
|
|
||||||
|
@Environment(\.scenePhase) var scenePhase
|
||||||
|
@State private var automaticAuthenticationAttempted = false
|
||||||
|
@State private var canConnectViewCall = false
|
||||||
|
@State private var lastSuccessfulUnlock: TimeInterval? = nil
|
||||||
|
|
||||||
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
||||||
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
||||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||||
@@ -28,8 +33,11 @@ struct ContentView: View {
|
|||||||
@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 chatListActionSheet: ChatListActionSheet? = nil
|
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||||
|
|
||||||
|
private let callTopPadding: CGFloat = 50
|
||||||
|
|
||||||
private enum ChatListActionSheet: Identifiable {
|
private enum ChatListActionSheet: Identifiable {
|
||||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||||
|
|
||||||
@@ -40,16 +48,53 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var accessAuthenticated: Bool {
|
||||||
|
chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
allViews()
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
} else {
|
||||||
|
// on iOS 15 scroll view background disabled in SceneDelegate
|
||||||
|
allViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func allViews() -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
contentView()
|
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
||||||
|
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
||||||
|
// i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() }
|
||||||
|
if !prefPerformLA || accessAuthenticated {
|
||||||
|
contentView()
|
||||||
|
.padding(.top, showCallArea ? callTopPadding : 0)
|
||||||
|
} else {
|
||||||
|
lockButton()
|
||||||
|
.padding(.top, showCallArea ? callTopPadding : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showCallArea, let call = chatModel.activeCall {
|
||||||
|
VStack {
|
||||||
|
activeCallInteractiveArea(call)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if chatModel.showCallView, let call = chatModel.activeCall {
|
if chatModel.showCallView, let call = chatModel.activeCall {
|
||||||
callView(call)
|
callView(call)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !showSettings, let la = chatModel.laRequest {
|
if !showSettings, let la = chatModel.laRequest {
|
||||||
LocalAuthView(authRequest: la)
|
LocalAuthView(authRequest: la)
|
||||||
|
.onDisappear {
|
||||||
|
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
|
||||||
|
waitingForOrPassedAuth = accessAuthenticated
|
||||||
|
}
|
||||||
} else if showSetPasscode {
|
} else if showSetPasscode {
|
||||||
SetAppPasscodeView {
|
SetAppPasscodeView {
|
||||||
|
chatModel.contentViewAccessAuthenticated = true
|
||||||
prefPerformLA = true
|
prefPerformLA = true
|
||||||
showSetPasscode = false
|
showSetPasscode = false
|
||||||
privacyLocalAuthModeDefault.set(.passcode)
|
privacyLocalAuthModeDefault.set(.passcode)
|
||||||
@@ -59,15 +104,10 @@ struct ContentView: View {
|
|||||||
showSetPasscode = false
|
showSetPasscode = false
|
||||||
alertManager.showAlert(laPasscodeNotSetAlert())
|
alertManager.showAlert(laPasscodeNotSetAlert())
|
||||||
}
|
}
|
||||||
|
} else if chatModel.chatDbStatus == nil && AppChatState.shared.value != .stopped && waitingForOrPassedAuth {
|
||||||
|
initializationView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
|
||||||
if prefPerformLA { requestNtfAuthorization() }
|
|
||||||
initAuthenticate()
|
|
||||||
}
|
|
||||||
.onChange(of: doAuthenticate) { _ in
|
|
||||||
initAuthenticate()
|
|
||||||
}
|
|
||||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||||
.sheet(isPresented: $showSettings) {
|
.sheet(isPresented: $showSettings) {
|
||||||
SettingsView(showSettings: $showSettings)
|
SettingsView(showSettings: $showSettings)
|
||||||
@@ -76,14 +116,55 @@ struct ContentView: View {
|
|||||||
Button("System authentication") { initialEnableLA() }
|
Button("System authentication") { initialEnableLA() }
|
||||||
Button("Passcode entry") { showSetPasscode = true }
|
Button("Passcode entry") { showSetPasscode = true }
|
||||||
}
|
}
|
||||||
|
.onChange(of: scenePhase) { phase in
|
||||||
|
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||||
|
switch (phase) {
|
||||||
|
case .background:
|
||||||
|
// also see .onChange(of: scenePhase) in SimpleXApp: on entering background
|
||||||
|
// it remembers enteredBackgroundAuthenticated and sets chatModel.contentViewAccessAuthenticated to false
|
||||||
|
automaticAuthenticationAttempted = false
|
||||||
|
canConnectViewCall = false
|
||||||
|
case .active:
|
||||||
|
canConnectViewCall = !prefPerformLA || contentAccessAuthenticationExtended || unlockedRecently()
|
||||||
|
|
||||||
|
// condition `!chatModel.contentViewAccessAuthenticated` is required for when authentication is enabled in settings or on initial notice
|
||||||
|
if prefPerformLA && !chatModel.contentViewAccessAuthenticated {
|
||||||
|
if AppChatState.shared.value != .stopped {
|
||||||
|
if contentAccessAuthenticationExtended {
|
||||||
|
chatModel.contentViewAccessAuthenticated = true
|
||||||
|
} else {
|
||||||
|
if !automaticAuthenticationAttempted {
|
||||||
|
automaticAuthenticationAttempted = true
|
||||||
|
// authenticate if call kit call is not in progress
|
||||||
|
if !(CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil) {
|
||||||
|
authenticateContentViewAccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// when app is stopped automatic authentication is not attempted
|
||||||
|
chatModel.contentViewAccessAuthenticated = contentAccessAuthenticationExtended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
reactOnDarkThemeChanges()
|
||||||
|
}
|
||||||
|
.onChange(of: colorScheme) { scheme in
|
||||||
|
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
|
||||||
|
// so when a user changes his global theme from dark to light or back, the app will adapt to it
|
||||||
|
reactOnDarkThemeChanges()
|
||||||
|
}
|
||||||
|
.onChange(of: theme.name) { _ in
|
||||||
|
ThemeManager.adjustWindowStyle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func contentView() -> some View {
|
@ViewBuilder private func contentView() -> some View {
|
||||||
if prefPerformLA && userAuthorized != true {
|
if let status = chatModel.chatDbStatus, status != .ok {
|
||||||
lockButton()
|
|
||||||
} else if chatModel.chatDbStatus == nil && showInitializationView {
|
|
||||||
initializationView()
|
|
||||||
} else if let status = chatModel.chatDbStatus, status != .ok {
|
|
||||||
DatabaseErrorView(status: status)
|
DatabaseErrorView(status: status)
|
||||||
} else if !chatModel.v3DBMigration.startChat {
|
} else if !chatModel.v3DBMigration.startChat {
|
||||||
MigrateToAppGroupView()
|
MigrateToAppGroupView()
|
||||||
@@ -91,11 +172,11 @@ struct ContentView: View {
|
|||||||
if case .onboardingComplete = step,
|
if case .onboardingComplete = step,
|
||||||
chatModel.currentUser != nil {
|
chatModel.currentUser != nil {
|
||||||
mainView()
|
mainView()
|
||||||
.actionSheet(item: $chatListActionSheet) { sheet in
|
.actionSheet(item: $chatListActionSheet) { sheet in
|
||||||
switch sheet {
|
switch sheet {
|
||||||
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
OnboardingView(onboarding: step)
|
OnboardingView(onboarding: step)
|
||||||
}
|
}
|
||||||
@@ -106,11 +187,11 @@ struct ContentView: View {
|
|||||||
if CallController.useCallKit() {
|
if CallController.useCallKit() {
|
||||||
ActiveCallView(call: call, canConnectCall: Binding.constant(true))
|
ActiveCallView(call: call, canConnectCall: Binding.constant(true))
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
if userAuthorized == false && doAuthenticate { runAuthenticate() }
|
if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ActiveCallView(call: call, canConnectCall: $canConnectCall)
|
ActiveCallView(call: call, canConnectCall: $canConnectViewCall)
|
||||||
if prefPerformLA && userAuthorized != true {
|
if prefPerformLA && !accessAuthenticated {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(colorScheme == .dark ? .black : .white)
|
.fill(colorScheme == .dark ? .black : .white)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -119,25 +200,64 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(call.contact.displayName).font(.body).foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
CallDuration(call: call)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.frame(height: callTopPadding - 10)
|
||||||
|
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
||||||
|
.onTapGesture {
|
||||||
|
chatModel.activeCallViewIsCollapsed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CallDuration: View {
|
||||||
|
let call: Call
|
||||||
|
@State var text: String = ""
|
||||||
|
@State var timer: Timer? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text).frame(minWidth: text.count <= 5 ? 52 : 77, alignment: .leading).offset(x: 4).font(.body).foregroundColor(.white)
|
||||||
|
.onAppear {
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in
|
||||||
|
if let connectedAt = call.connectedAt {
|
||||||
|
text = durationText(Int(Date.now.timeIntervalSince1970 - connectedAt.timeIntervalSince1970))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
_ = timer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func lockButton() -> some View {
|
private func lockButton() -> some View {
|
||||||
Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") }
|
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initializationView() -> some View {
|
private func initializationView() -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
ProgressView().scaleEffect(2)
|
ProgressView().scaleEffect(2)
|
||||||
Text("Opening database…")
|
Text("Opening app…")
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
||||||
|
.background(
|
||||||
|
Rectangle()
|
||||||
|
.fill(theme.colors.background)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mainView() -> some View {
|
private func mainView() -> some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if !prefPerformLA { 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
|
||||||
if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) {
|
if (!prefLANoticeShown && prefShowLANotice && chatModel.chats.count > 2) {
|
||||||
prefLANoticeShown = true
|
prefLANoticeShown = true
|
||||||
alertManager.showAlert(laNoticeAlert())
|
alertManager.showAlert(laNoticeAlert())
|
||||||
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
|
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
|
||||||
@@ -187,48 +307,37 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initAuthenticate() {
|
private func unlockedRecently() -> Bool {
|
||||||
logger.debug("initAuthenticate")
|
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
||||||
if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil {
|
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
||||||
userAuthorized = false
|
|
||||||
} else if doAuthenticate {
|
|
||||||
runAuthenticate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func runAuthenticate() {
|
|
||||||
logger.debug("DEBUGGING: runAuthenticate")
|
|
||||||
if !prefPerformLA {
|
|
||||||
userAuthorized = true
|
|
||||||
} else {
|
} else {
|
||||||
logger.debug("DEBUGGING: before dismissAllSheets")
|
return false
|
||||||
dismissAllSheets(animated: false) {
|
|
||||||
logger.debug("DEBUGGING: in dismissAllSheets callback")
|
|
||||||
chatModel.chatId = nil
|
|
||||||
justAuthenticate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func justAuthenticate() {
|
private func authenticateContentViewAccess() {
|
||||||
userAuthorized = false
|
logger.debug("DEBUGGING: authenticateContentViewAccess")
|
||||||
let laMode = privacyLocalAuthModeDefault.get()
|
dismissAllSheets(animated: false) {
|
||||||
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback")
|
||||||
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
|
chatModel.chatId = nil
|
||||||
switch (laResult) {
|
|
||||||
case .success:
|
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
||||||
userAuthorized = true
|
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
|
||||||
canConnectCall = true
|
switch (laResult) {
|
||||||
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
|
case .success:
|
||||||
case .failed:
|
chatModel.contentViewAccessAuthenticated = true
|
||||||
if laMode == .passcode {
|
canConnectViewCall = true
|
||||||
AlertManager.shared.showAlert(laFailedAlert())
|
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
|
||||||
|
case .failed:
|
||||||
|
chatModel.contentViewAccessAuthenticated = false
|
||||||
|
if privacyLocalAuthModeDefault.get() == .passcode {
|
||||||
|
AlertManager.shared.showAlert(laFailedAlert())
|
||||||
|
}
|
||||||
|
case .unavailable:
|
||||||
|
prefPerformLA = false
|
||||||
|
canConnectViewCall = true
|
||||||
|
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
|
||||||
}
|
}
|
||||||
case .unavailable:
|
|
||||||
userAuthorized = true
|
|
||||||
prefPerformLA = false
|
|
||||||
canConnectCall = true
|
|
||||||
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,6 +368,7 @@ struct ContentView: View {
|
|||||||
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
||||||
switch laResult {
|
switch laResult {
|
||||||
case .success:
|
case .success:
|
||||||
|
chatModel.contentViewAccessAuthenticated = true
|
||||||
prefPerformLA = true
|
prefPerformLA = true
|
||||||
alertManager.showAlert(laTurnedOnAlert())
|
alertManager.showAlert(laTurnedOnAlert())
|
||||||
case .failed:
|
case .failed:
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
|||||||
if playback {
|
if playback {
|
||||||
if AVAudioSession.sharedInstance().category != .playback {
|
if AVAudioSession.sharedInstance().category != .playback {
|
||||||
logger.log("AudioSession: playback")
|
logger.log("AudioSession: playback")
|
||||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: .duckOthers)
|
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: [.duckOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if AVAudioSession.sharedInstance().category != .soloAmbient {
|
if AVAudioSession.sharedInstance().category != .soloAmbient {
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ private let receiveTaskId = "chat.simplex.app.receive"
|
|||||||
// TCP timeout + 2 sec
|
// TCP timeout + 2 sec
|
||||||
private let waitForMessages: TimeInterval = 6
|
private let waitForMessages: TimeInterval = 6
|
||||||
|
|
||||||
private let bgRefreshInterval: TimeInterval = 450
|
// This is the smallest interval between refreshes, and also target interval in "off" mode
|
||||||
|
private let bgRefreshInterval: TimeInterval = 600 // 10 minutes
|
||||||
|
|
||||||
|
// This intervals are used for background refresh in instant and periodic modes
|
||||||
|
private let periodicBgRefreshInterval: TimeInterval = 1200 // 20 minutes
|
||||||
|
|
||||||
|
private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes
|
||||||
|
|
||||||
private let maxTimerCount = 9
|
private let maxTimerCount = 9
|
||||||
|
|
||||||
@@ -33,14 +39,14 @@ class BGManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func schedule() {
|
func schedule(interval: TimeInterval? = nil) {
|
||||||
if !ChatModel.shared.ntfEnableLocal {
|
if !ChatModel.shared.ntfEnableLocal {
|
||||||
logger.debug("BGManager.schedule: disabled")
|
logger.debug("BGManager.schedule: disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.debug("BGManager.schedule")
|
logger.debug("BGManager.schedule")
|
||||||
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
|
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
|
||||||
request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval)
|
request.earliestBeginDate = Date(timeIntervalSinceNow: interval ?? runInterval)
|
||||||
do {
|
do {
|
||||||
try BGTaskScheduler.shared.submit(request)
|
try BGTaskScheduler.shared.submit(request)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -48,20 +54,34 @@ class BGManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var runInterval: TimeInterval {
|
||||||
|
switch ChatModel.shared.notificationMode {
|
||||||
|
case .instant: maxBgRefreshInterval
|
||||||
|
case .periodic: periodicBgRefreshInterval
|
||||||
|
case .off: bgRefreshInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastRanLongAgo: Bool {
|
||||||
|
Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval
|
||||||
|
}
|
||||||
|
|
||||||
private func handleRefresh(_ task: BGAppRefreshTask) {
|
private func handleRefresh(_ task: BGAppRefreshTask) {
|
||||||
if !ChatModel.shared.ntfEnableLocal {
|
if !ChatModel.shared.ntfEnableLocal {
|
||||||
logger.debug("BGManager.handleRefresh: disabled")
|
logger.debug("BGManager.handleRefresh: disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.debug("BGManager.handleRefresh")
|
logger.debug("BGManager.handleRefresh")
|
||||||
schedule()
|
let shouldRun_ = lastRanLongAgo
|
||||||
if appStateGroupDefault.get().inactive {
|
if allowBackgroundRefresh() && shouldRun_ {
|
||||||
|
schedule()
|
||||||
let completeRefresh = completionHandler {
|
let completeRefresh = completionHandler {
|
||||||
task.setTaskCompleted(success: true)
|
task.setTaskCompleted(success: true)
|
||||||
}
|
}
|
||||||
task.expirationHandler = { completeRefresh("expirationHandler") }
|
task.expirationHandler = { completeRefresh("expirationHandler") }
|
||||||
receiveMessages(completeRefresh)
|
receiveMessages(completeRefresh)
|
||||||
} else {
|
} else {
|
||||||
|
schedule(interval: shouldRun_ ? bgRefreshInterval : runInterval)
|
||||||
logger.debug("BGManager.completionHandler: already active, not started")
|
logger.debug("BGManager.completionHandler: already active, not started")
|
||||||
task.setTaskCompleted(success: true)
|
task.setTaskCompleted(success: true)
|
||||||
}
|
}
|
||||||
@@ -90,20 +110,22 @@ class BGManager {
|
|||||||
}
|
}
|
||||||
self.completed = false
|
self.completed = false
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
chatLastBackgroundRunGroupDefault.set(Date.now)
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
if (!m.chatInitialized) {
|
if (!m.chatInitialized) {
|
||||||
|
setAppState(.bgRefresh)
|
||||||
do {
|
do {
|
||||||
try initializeChat(start: true)
|
try initializeChat(start: true)
|
||||||
} catch let error {
|
} catch let error {
|
||||||
fatalError("Failed to start or load chats: \(responseError(error))")
|
fatalError("Failed to start or load chats: \(responseError(error))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
activateChat(appState: .bgRefresh)
|
||||||
if m.currentUser == nil {
|
if m.currentUser == nil {
|
||||||
completeReceiving("no current user")
|
completeReceiving("no current user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.debug("BGManager.receiveMessages: starting chat")
|
logger.debug("BGManager.receiveMessages: starting chat")
|
||||||
activateChat(appState: .bgRefresh)
|
|
||||||
let cr = ChatReceiver()
|
let cr = ChatReceiver()
|
||||||
self.chatReceiver = cr
|
self.chatReceiver = cr
|
||||||
cr.start()
|
cr.start()
|
||||||
|
|||||||
@@ -47,16 +47,24 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var onboardingStage: OnboardingStage?
|
@Published var onboardingStage: OnboardingStage?
|
||||||
@Published var setDeliveryReceipts = false
|
@Published var setDeliveryReceipts = false
|
||||||
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
@Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get()
|
||||||
@Published var currentUser: User?
|
@Published var currentUser: User? {
|
||||||
|
didSet {
|
||||||
|
ThemeManager.applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
@Published var users: [UserInfo] = []
|
@Published var users: [UserInfo] = []
|
||||||
@Published var chatInitialized = false
|
@Published var chatInitialized = false
|
||||||
@Published var chatRunning: Bool?
|
@Published var chatRunning: Bool?
|
||||||
@Published var chatDbChanged = false
|
@Published var chatDbChanged = false
|
||||||
@Published var chatDbEncrypted: Bool?
|
@Published var chatDbEncrypted: Bool?
|
||||||
@Published var chatDbStatus: DBMigrationResult?
|
@Published var chatDbStatus: DBMigrationResult?
|
||||||
|
@Published var ctrlInitInProgress: Bool = false
|
||||||
|
// local authentication
|
||||||
|
@Published var contentViewAccessAuthenticated: Bool = false
|
||||||
@Published var laRequest: LocalAuthRequest?
|
@Published var laRequest: LocalAuthRequest?
|
||||||
// list of chat "previews"
|
// list of chat "previews"
|
||||||
@Published var chats: [Chat] = []
|
@Published var chats: [Chat] = []
|
||||||
|
@Published var deletedChats: Set<String> = []
|
||||||
// map of connections network statuses, key is agent connection id
|
// map of connections network statuses, key is agent connection id
|
||||||
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
||||||
// current chat
|
// current chat
|
||||||
@@ -76,6 +84,7 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var tokenRegistered = false
|
@Published var tokenRegistered = false
|
||||||
@Published var tokenStatus: NtfTknStatus?
|
@Published var tokenStatus: NtfTknStatus?
|
||||||
@Published var notificationMode = NotificationsMode.off
|
@Published var notificationMode = NotificationsMode.off
|
||||||
|
@Published var notificationServer: String?
|
||||||
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
@Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||||
// pending notification actions
|
// pending notification actions
|
||||||
@Published var ntfContactRequest: NTFContactRequest?
|
@Published var ntfContactRequest: NTFContactRequest?
|
||||||
@@ -83,18 +92,22 @@ final class ChatModel: ObservableObject {
|
|||||||
// current WebRTC call
|
// current WebRTC call
|
||||||
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
|
@Published var callInvitations: Dictionary<ChatId, RcvCallInvitation> = [:]
|
||||||
@Published var activeCall: Call?
|
@Published var activeCall: Call?
|
||||||
@Published var callCommand: WCallCommand?
|
let callCommand: WebRTCCommandProcessor = WebRTCCommandProcessor()
|
||||||
@Published var showCallView = false
|
@Published var showCallView = false
|
||||||
|
@Published var activeCallViewIsCollapsed = false
|
||||||
// remote desktop
|
// remote desktop
|
||||||
@Published var remoteCtrlSession: RemoteCtrlSession?
|
@Published var remoteCtrlSession: RemoteCtrlSession?
|
||||||
// currently showing QR code
|
// currently showing invitation
|
||||||
@Published var connReqInv: String?
|
@Published var showingInvitation: ShowingInvitation?
|
||||||
|
@Published var migrationState: MigrationToState? = MigrationToDeviceState.makeMigrationState()
|
||||||
// audio recording and playback
|
// audio recording and playback
|
||||||
@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
|
// tracks keyboard height via subscription in AppDelegate
|
||||||
@Published var keyboardHeight: CGFloat = 0
|
@Published var keyboardHeight: CGFloat = 0
|
||||||
|
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
|
||||||
|
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||||
|
|
||||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||||
|
|
||||||
@@ -104,12 +117,10 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||||
|
|
||||||
var ntfEnableLocal: Bool {
|
let ntfEnableLocal = true
|
||||||
notificationMode == .off || ntfEnableLocalGroupDefault.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
var ntfEnablePeriodic: Bool {
|
var ntfEnablePeriodic: Bool {
|
||||||
notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get()
|
notificationMode != .off
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeRemoteCtrl: Bool {
|
var activeRemoteCtrl: Bool {
|
||||||
@@ -136,7 +147,7 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeUser(_ user: User) {
|
func removeUser(_ user: User) {
|
||||||
if let i = getUserIndex(user), users[i].user.userId != currentUser?.userId {
|
if let i = getUserIndex(user) {
|
||||||
users.remove(at: i)
|
users.remove(at: i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,7 +278,20 @@ final class ChatModel: ObservableObject {
|
|||||||
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
// update previews
|
// update previews
|
||||||
if let i = getChatIndex(cInfo.id) {
|
if let i = getChatIndex(cInfo.id) {
|
||||||
chats[i].chatItems = [cItem]
|
chats[i].chatItems = switch cInfo {
|
||||||
|
case .group:
|
||||||
|
if let currentPreviewItem = chats[i].chatItems.first {
|
||||||
|
if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs {
|
||||||
|
[cItem]
|
||||||
|
} else {
|
||||||
|
[currentPreviewItem]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
[cItem]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
[cItem]
|
||||||
|
}
|
||||||
if case .rcvNew = cItem.meta.itemStatus {
|
if case .rcvNew = cItem.meta.itemStatus {
|
||||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
||||||
increaseUnreadCounter(user: currentUser!)
|
increaseUnreadCounter(user: currentUser!)
|
||||||
@@ -312,12 +336,12 @@ 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) {
|
||||||
withAnimation {
|
withConditionalAnimation {
|
||||||
_updateChatItem(at: i, with: cItem)
|
_updateChatItem(at: i, with: cItem)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
withAnimation(itemAnimation()) {
|
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
|
||||||
@@ -337,7 +361,7 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
||||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||||
withAnimation {
|
withConditionalAnimation {
|
||||||
_updateChatItem(at: i, with: cItem)
|
_updateChatItem(at: i, with: cItem)
|
||||||
}
|
}
|
||||||
} else if let status = status {
|
} else if let status = status {
|
||||||
@@ -402,6 +426,16 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateCurrentUserUiThemes(uiThemes: ThemeModeOverrides?) {
|
||||||
|
guard var current = currentUser else { return }
|
||||||
|
current.uiThemes = uiThemes
|
||||||
|
let i = users.firstIndex(where: { $0.user.userId == current.userId })
|
||||||
|
if let i {
|
||||||
|
users[i].user = current
|
||||||
|
}
|
||||||
|
currentUser = current
|
||||||
|
}
|
||||||
|
|
||||||
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
||||||
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@@ -492,11 +526,13 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
// update preview
|
|
||||||
decreaseUnreadCounter(cInfo)
|
|
||||||
// update current chat
|
|
||||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||||
markChatItemRead_(i)
|
if reversedChatItems[i].isRcvNew {
|
||||||
|
// update current chat
|
||||||
|
markChatItemRead_(i)
|
||||||
|
// update preview
|
||||||
|
decreaseUnreadCounter(cInfo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,14 +643,16 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func dismissConnReqView(_ id: String) {
|
func dismissConnReqView(_ id: String) {
|
||||||
if let connReqInv = connReqInv,
|
if id == showingInvitation?.connId {
|
||||||
let c = getChat(id),
|
markShowingInvitationUsed()
|
||||||
case let .contactConnection(contactConnection) = c.chatInfo,
|
|
||||||
connReqInv == contactConnection.connReqInv {
|
|
||||||
dismissAllSheets()
|
dismissAllSheets()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markShowingInvitationUsed() {
|
||||||
|
showingInvitation?.connChatUsed = true
|
||||||
|
}
|
||||||
|
|
||||||
func removeChat(_ id: String) {
|
func removeChat(_ id: String) {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
chats.removeAll(where: { $0.id == id })
|
chats.removeAll(where: { $0.id == id })
|
||||||
@@ -691,12 +729,17 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ShowingInvitation {
|
||||||
|
var connId: String
|
||||||
|
var connChatUsed: Bool
|
||||||
|
}
|
||||||
|
|
||||||
struct NTFContactRequest {
|
struct NTFContactRequest {
|
||||||
var incognito: Bool
|
var incognito: Bool
|
||||||
var chatId: String
|
var chatId: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UnreadChatItemCounts {
|
struct UnreadChatItemCounts: Equatable {
|
||||||
var totalBelow: Int
|
var totalBelow: Int
|
||||||
var unreadBelow: Int
|
var unreadBelow: Int
|
||||||
}
|
}
|
||||||
@@ -733,6 +776,8 @@ final class Chat: ObservableObject, Identifiable {
|
|||||||
case let .group(groupInfo):
|
case let .group(groupInfo):
|
||||||
let m = groupInfo.membership
|
let m = groupInfo.membership
|
||||||
return m.memberActive && m.memberRole >= .member
|
return m.memberActive && m.memberRole >= .member
|
||||||
|
case .local:
|
||||||
|
return true
|
||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -750,6 +795,24 @@ final class Chat: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
||||||
|
|
||||||
|
func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
|
||||||
|
if case let .group(groupInfo) = self.chatInfo {
|
||||||
|
let p = groupInfo.fullGroupPreferences
|
||||||
|
return switch feature {
|
||||||
|
case .timedMessages: p.timedMessages.on
|
||||||
|
case .directMessages: p.directMessages.on(for: groupInfo.membership)
|
||||||
|
case .fullDelete: p.fullDelete.on
|
||||||
|
case .reactions: p.reactions.on
|
||||||
|
case .voice: p.voice.on(for: groupInfo.membership)
|
||||||
|
case .files: p.files.on(for: groupInfo.membership)
|
||||||
|
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
|
||||||
|
case .history: p.history.on
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ func imageHasAlpha(_ img: UIImage) -> Bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? {
|
func saveFileFromURL(_ url: URL) -> CryptoFile? {
|
||||||
|
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
||||||
let savedFile: CryptoFile?
|
let savedFile: CryptoFile?
|
||||||
if url.startAccessingSecurityScopedResource() {
|
if url.startAccessingSecurityScopedResource() {
|
||||||
do {
|
do {
|
||||||
@@ -185,28 +186,74 @@ func saveFileFromURL(_ url: URL, encrypted: Bool) -> CryptoFile? {
|
|||||||
|
|
||||||
func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
||||||
do {
|
do {
|
||||||
|
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
||||||
let fileName = uniqueCombine(url.lastPathComponent)
|
let fileName = uniqueCombine(url.lastPathComponent)
|
||||||
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
|
let savedFile: CryptoFile?
|
||||||
|
if encrypted {
|
||||||
|
let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: getAppFilePath(fileName).path)
|
||||||
|
try FileManager.default.removeItem(atPath: url.path)
|
||||||
|
savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs)
|
||||||
|
} else {
|
||||||
|
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
|
||||||
|
savedFile = CryptoFile.plain(fileName)
|
||||||
|
}
|
||||||
ChatModel.shared.filesToDelete.remove(url)
|
ChatModel.shared.filesToDelete.remove(url)
|
||||||
return CryptoFile.plain(fileName)
|
return savedFile
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
|
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateNewFileName(_ prefix: String, _ ext: String) -> String {
|
func saveWallpaperFile(url: URL) -> String? {
|
||||||
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)")
|
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true))
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
|
||||||
|
return destFile.lastPathComponent
|
||||||
|
} catch {
|
||||||
|
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uniqueCombine(_ fileName: String) -> String {
|
func saveWallpaperFile(image: UIImage) -> String? {
|
||||||
|
let hasAlpha = imageHasAlpha(image)
|
||||||
|
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true))
|
||||||
|
let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha)
|
||||||
|
do {
|
||||||
|
try dataResized!.write(to: destFile)
|
||||||
|
return destFile.lastPathComponent
|
||||||
|
} catch {
|
||||||
|
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeWallpaperFile(fileName: String? = nil) {
|
||||||
|
do {
|
||||||
|
try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach {
|
||||||
|
if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("FileUtils.removeWallpaperFile error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
if let fileName {
|
||||||
|
WallpaperType.cachedImages.removeValue(forKey: fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
|
||||||
|
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String {
|
||||||
func tryCombine(_ fileName: String, _ n: Int) -> String {
|
func tryCombine(_ fileName: String, _ n: Int) -> String {
|
||||||
let ns = fileName as NSString
|
let ns = fileName as NSString
|
||||||
let name = ns.deletingPathExtension
|
let name = ns.deletingPathExtension
|
||||||
let ext = ns.pathExtension
|
let ext = ns.pathExtension
|
||||||
let suffix = (n == 0) ? "" : "_\(n)"
|
let suffix = (n == 0) ? "" : "_\(n)"
|
||||||
let f = "\(name)\(suffix).\(ext)"
|
let f = "\(name)\(suffix).\(ext)"
|
||||||
return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
|
return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
|
||||||
}
|
}
|
||||||
return tryCombine(fileName, 0)
|
return tryCombine(fileName, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//
|
||||||
|
// NSESubscriber.swift
|
||||||
|
// SimpleXChat
|
||||||
|
//
|
||||||
|
// Created by Evgeny on 09/12/2023.
|
||||||
|
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
private var nseSubscribers: [UUID:NSESubscriber] = [:]
|
||||||
|
|
||||||
|
// timeout for active notification service extension going into "suspending" state.
|
||||||
|
// If in two seconds the state does not change, we assume that it was not running and proceed with app activation/answering call.
|
||||||
|
private let SUSPENDING_TIMEOUT: TimeInterval = 2
|
||||||
|
|
||||||
|
// timeout should be larger than SUSPENDING_TIMEOUT
|
||||||
|
func waitNSESuspended(timeout: TimeInterval, suspended: @escaping (Bool) -> Void) {
|
||||||
|
if timeout <= SUSPENDING_TIMEOUT {
|
||||||
|
logger.warning("waitNSESuspended: small timeout \(timeout), using \(SUSPENDING_TIMEOUT + 1)")
|
||||||
|
}
|
||||||
|
var state = nseStateGroupDefault.get()
|
||||||
|
if case .suspended = state {
|
||||||
|
DispatchQueue.main.async { suspended(true) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let id = UUID()
|
||||||
|
var suspendedCalled = false
|
||||||
|
checkTimeout()
|
||||||
|
nseSubscribers[id] = nseMessageSubscriber { msg in
|
||||||
|
if case let .state(newState) = msg {
|
||||||
|
state = newState
|
||||||
|
logger.debug("waitNSESuspended state: \(state.rawValue)")
|
||||||
|
if case .suspended = newState {
|
||||||
|
notifySuspended(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
func notifySuspended(_ ok: Bool) {
|
||||||
|
logger.debug("waitNSESuspended notifySuspended: \(ok)")
|
||||||
|
if !suspendedCalled {
|
||||||
|
logger.debug("waitNSESuspended notifySuspended: calling suspended(\(ok))")
|
||||||
|
suspendedCalled = true
|
||||||
|
nseSubscribers.removeValue(forKey: id)
|
||||||
|
DispatchQueue.main.async { suspended(ok) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkTimeout() {
|
||||||
|
if !suspending() {
|
||||||
|
checkSuspendingTimeout()
|
||||||
|
} else if state == .suspending {
|
||||||
|
checkSuspendedTimeout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func suspending() -> Bool {
|
||||||
|
suspendedCalled || state == .suspended || state == .suspending
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSuspendingTimeout() {
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + SUSPENDING_TIMEOUT) {
|
||||||
|
logger.debug("waitNSESuspended check suspending timeout")
|
||||||
|
if !suspending() {
|
||||||
|
notifySuspended(false)
|
||||||
|
} else if state != .suspended {
|
||||||
|
checkSuspendedTimeout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSuspendedTimeout() {
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + min(timeout - SUSPENDING_TIMEOUT, 1)) {
|
||||||
|
logger.debug("waitNSESuspended check suspended timeout")
|
||||||
|
if state != .suspended {
|
||||||
|
notifySuspended(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// NetworkObserver.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 05.04.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
class NetworkObserver {
|
||||||
|
static let shared = NetworkObserver()
|
||||||
|
private let queue: DispatchQueue = DispatchQueue(label: "chat.simplex.app.NetworkObserver")
|
||||||
|
private var prevInfo: UserNetworkInfo? = nil
|
||||||
|
private var monitor: NWPathMonitor?
|
||||||
|
private let monitorLock: DispatchQueue = DispatchQueue(label: "chat.simplex.app.monitorLock")
|
||||||
|
|
||||||
|
func restartMonitor() {
|
||||||
|
monitorLock.sync {
|
||||||
|
monitor?.cancel()
|
||||||
|
let mon = NWPathMonitor()
|
||||||
|
mon.pathUpdateHandler = { [weak self] path in
|
||||||
|
self?.networkPathChanged(path: path)
|
||||||
|
}
|
||||||
|
mon.start(queue: queue)
|
||||||
|
monitor = mon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func networkPathChanged(path: NWPath) {
|
||||||
|
let info = UserNetworkInfo(
|
||||||
|
networkType: networkTypeFromPath(path),
|
||||||
|
online: path.status == .satisfied
|
||||||
|
)
|
||||||
|
if (prevInfo != info) {
|
||||||
|
prevInfo = info
|
||||||
|
setNetworkInfo(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func networkTypeFromPath(_ path: NWPath) -> UserNetworkType {
|
||||||
|
if path.usesInterfaceType(.wiredEthernet) {
|
||||||
|
.ethernet
|
||||||
|
} else if path.usesInterfaceType(.wifi) {
|
||||||
|
.wifi
|
||||||
|
} else if path.usesInterfaceType(.cellular) {
|
||||||
|
.cellular
|
||||||
|
} else if path.usesInterfaceType(.other) {
|
||||||
|
.other
|
||||||
|
} else {
|
||||||
|
.none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var networkObserver: NetworkObserver? = nil
|
||||||
|
|
||||||
|
private func setNetworkInfo(_ info: UserNetworkInfo) {
|
||||||
|
logger.debug("setNetworkInfo Network changed: \(String(describing: info))")
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
ChatModel.shared.networkInfo = info
|
||||||
|
}
|
||||||
|
if !hasChatCtrl() { return }
|
||||||
|
self.monitorLock.sync {
|
||||||
|
do {
|
||||||
|
try apiSetNetworkInfo(info)
|
||||||
|
} catch let err {
|
||||||
|
logger.error("setNetworkInfo error: \(responseError(err))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,27 +9,30 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock")
|
private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock")
|
||||||
|
|
||||||
let appSuspendTimeout: Int = 15 // seconds
|
|
||||||
|
|
||||||
let bgSuspendTimeout: Int = 5 // seconds
|
let bgSuspendTimeout: Int = 5 // seconds
|
||||||
|
|
||||||
let terminationTimeout: Int = 3 // seconds
|
let terminationTimeout: Int = 3 // seconds
|
||||||
|
|
||||||
|
let activationDelay: TimeInterval = 1.5
|
||||||
|
|
||||||
|
let nseSuspendTimeout: TimeInterval = 5
|
||||||
|
|
||||||
private func _suspendChat(timeout: Int) {
|
private func _suspendChat(timeout: Int) {
|
||||||
// this is a redundant check to prevent logical errors, like the one fixed in this PR
|
// this is a redundant check to prevent logical errors, like the one fixed in this PR
|
||||||
let state = appStateGroupDefault.get()
|
let state = AppChatState.shared.value
|
||||||
if !state.canSuspend {
|
if !state.canSuspend {
|
||||||
logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)")
|
logger.error("_suspendChat called, current state: \(state.rawValue)")
|
||||||
} else if ChatModel.ok {
|
} else if ChatModel.ok {
|
||||||
appStateGroupDefault.set(.suspending)
|
AppChatState.shared.set(.suspending)
|
||||||
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
|
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
|
||||||
let endTask = beginBGTask(chatSuspended)
|
let endTask = beginBGTask(chatSuspended)
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
|
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask)
|
||||||
} else {
|
} else {
|
||||||
appStateGroupDefault.set(.suspended)
|
AppChatState.shared.set(.suspended)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,18 +44,16 @@ func suspendChat() {
|
|||||||
|
|
||||||
func suspendBgRefresh() {
|
func suspendBgRefresh() {
|
||||||
suspendLockQueue.sync {
|
suspendLockQueue.sync {
|
||||||
if case .bgRefresh = appStateGroupDefault.get() {
|
if case .bgRefresh = AppChatState.shared.value {
|
||||||
_suspendChat(timeout: bgSuspendTimeout)
|
_suspendChat(timeout: bgSuspendTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var terminating = false
|
|
||||||
|
|
||||||
func terminateChat() {
|
func terminateChat() {
|
||||||
logger.debug("terminateChat")
|
logger.debug("terminateChat")
|
||||||
suspendLockQueue.sync {
|
suspendLockQueue.sync {
|
||||||
switch appStateGroupDefault.get() {
|
switch AppChatState.shared.value {
|
||||||
case .suspending:
|
case .suspending:
|
||||||
// suspend instantly if already suspending
|
// suspend instantly if already suspending
|
||||||
_chatSuspended()
|
_chatSuspended()
|
||||||
@@ -64,7 +65,6 @@ func terminateChat() {
|
|||||||
case .stopped:
|
case .stopped:
|
||||||
chatCloseStore()
|
chatCloseStore()
|
||||||
default:
|
default:
|
||||||
terminating = true
|
|
||||||
// the store will be closed in _chatSuspended when event is received
|
// the store will be closed in _chatSuspended when event is received
|
||||||
_suspendChat(timeout: terminationTimeout)
|
_suspendChat(timeout: terminationTimeout)
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func terminateChat() {
|
|||||||
|
|
||||||
func chatSuspended() {
|
func chatSuspended() {
|
||||||
suspendLockQueue.sync {
|
suspendLockQueue.sync {
|
||||||
if case .suspending = appStateGroupDefault.get() {
|
if case .suspending = AppChatState.shared.value {
|
||||||
_chatSuspended()
|
_chatSuspended()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,48 +81,111 @@ func chatSuspended() {
|
|||||||
|
|
||||||
private func _chatSuspended() {
|
private func _chatSuspended() {
|
||||||
logger.debug("_chatSuspended")
|
logger.debug("_chatSuspended")
|
||||||
appStateGroupDefault.set(.suspended)
|
AppChatState.shared.set(.suspended)
|
||||||
if ChatModel.shared.chatRunning == true {
|
if ChatModel.shared.chatRunning == true {
|
||||||
ChatReceiver.shared.stop()
|
ChatReceiver.shared.stop()
|
||||||
}
|
}
|
||||||
if terminating {
|
chatCloseStore()
|
||||||
chatCloseStore()
|
}
|
||||||
|
|
||||||
|
func setAppState(_ appState: AppState) {
|
||||||
|
suspendLockQueue.sync {
|
||||||
|
AppChatState.shared.set(appState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func activateChat(appState: AppState = .active) {
|
func activateChat(appState: AppState = .active) {
|
||||||
logger.debug("DEBUGGING: activateChat")
|
logger.debug("DEBUGGING: activateChat")
|
||||||
terminating = false
|
|
||||||
suspendLockQueue.sync {
|
suspendLockQueue.sync {
|
||||||
appStateGroupDefault.set(appState)
|
AppChatState.shared.set(appState)
|
||||||
if ChatModel.ok { apiActivateChat() }
|
if ChatModel.ok { apiActivateChat() }
|
||||||
logger.debug("DEBUGGING: activateChat: after apiActivateChat")
|
logger.debug("DEBUGGING: activateChat: after apiActivateChat")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func initChatAndMigrate(refreshInvitations: Bool = true) {
|
func initChatAndMigrate(refreshInvitations: Bool = true) {
|
||||||
terminating = false
|
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
if (!m.chatInitialized) {
|
if (!m.chatInitialized) {
|
||||||
|
m.v3DBMigration = v3DBMigrationDefault.get()
|
||||||
|
if AppChatState.shared.value == .stopped && storeDBPassphraseGroupDefault.get() && kcDatabasePassword.get() != nil {
|
||||||
|
initialize(start: true, confirmStart: true)
|
||||||
|
} else {
|
||||||
|
initialize(start: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initialize(start: Bool, confirmStart: Bool = false) {
|
||||||
do {
|
do {
|
||||||
m.v3DBMigration = v3DBMigrationDefault.get()
|
try initializeChat(start: m.v3DBMigration.startChat && start, confirmStart: m.v3DBMigration.startChat && confirmStart, refreshInvitations: refreshInvitations)
|
||||||
try initializeChat(start: m.v3DBMigration.startChat, refreshInvitations: refreshInvitations)
|
|
||||||
} catch let error {
|
} catch let error {
|
||||||
fatalError("Failed to start or load chats: \(responseError(error))")
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title: start ? "Error starting chat" : "Error opening chat",
|
||||||
|
message: "Please contact developers.\nError: \(responseError(error))"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startChatAndActivate() {
|
func startChatForCall() {
|
||||||
terminating = false
|
logger.debug("DEBUGGING: startChatForCall")
|
||||||
|
if ChatModel.shared.chatRunning == true {
|
||||||
|
ChatReceiver.shared.start()
|
||||||
|
logger.debug("DEBUGGING: startChatForCall: after ChatReceiver.shared.start")
|
||||||
|
}
|
||||||
|
if .active != AppChatState.shared.value {
|
||||||
|
logger.debug("DEBUGGING: startChatForCall: before activateChat")
|
||||||
|
activateChat()
|
||||||
|
logger.debug("DEBUGGING: startChatForCall: after activateChat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startChatAndActivate(_ completion: @escaping () -> Void) {
|
||||||
logger.debug("DEBUGGING: startChatAndActivate")
|
logger.debug("DEBUGGING: startChatAndActivate")
|
||||||
if ChatModel.shared.chatRunning == true {
|
if ChatModel.shared.chatRunning == true {
|
||||||
ChatReceiver.shared.start()
|
ChatReceiver.shared.start()
|
||||||
logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start")
|
logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start")
|
||||||
}
|
}
|
||||||
if .active != appStateGroupDefault.get() {
|
if case .active = AppChatState.shared.value {
|
||||||
|
completion()
|
||||||
|
} else if nseStateGroupDefault.get().inactive {
|
||||||
|
activate()
|
||||||
|
} else {
|
||||||
|
// setting app state to "activating" to notify NSE that it should suspend
|
||||||
|
setAppState(.activating)
|
||||||
|
waitNSESuspended(timeout: nseSuspendTimeout) { ok in
|
||||||
|
if !ok {
|
||||||
|
// if for some reason NSE failed to suspend,
|
||||||
|
// e.g., it crashed previously without setting its state to "suspended",
|
||||||
|
// set it to "suspended" state anyway, so that next time app
|
||||||
|
// does not have to wait when activating.
|
||||||
|
nseStateGroupDefault.set(.suspended)
|
||||||
|
}
|
||||||
|
if AppChatState.shared.value == .activating {
|
||||||
|
activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
logger.debug("DEBUGGING: startChatAndActivate: before activateChat")
|
logger.debug("DEBUGGING: startChatAndActivate: before activateChat")
|
||||||
activateChat()
|
activateChat()
|
||||||
|
completion()
|
||||||
logger.debug("DEBUGGING: startChatAndActivate: after activateChat")
|
logger.debug("DEBUGGING: startChatAndActivate: after activateChat")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appStateGroupDefault must not be used in the app directly, only via this singleton
|
||||||
|
class AppChatState {
|
||||||
|
static let shared = AppChatState()
|
||||||
|
private var value_ = appStateGroupDefault.get()
|
||||||
|
|
||||||
|
var value: AppState {
|
||||||
|
value_
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ state: AppState) {
|
||||||
|
appStateGroupDefault.set(state)
|
||||||
|
sendAppState(state)
|
||||||
|
value_ = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,14 +16,9 @@ struct SimpleXApp: App {
|
|||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
@StateObject private var chatModel = ChatModel.shared
|
@StateObject private var chatModel = ChatModel.shared
|
||||||
@ObservedObject var alertManager = AlertManager.shared
|
@ObservedObject var alertManager = AlertManager.shared
|
||||||
|
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil
|
||||||
@State private var userAuthorized: Bool?
|
|
||||||
@State private var doAuthenticate = false
|
|
||||||
@State private var enteredBackground: TimeInterval? = nil
|
|
||||||
@State private var canConnectCall = false
|
|
||||||
@State private var lastSuccessfulUnlock: TimeInterval? = nil
|
|
||||||
@State private var showInitializationView = false
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
DispatchQueue.global(qos: .background).sync {
|
DispatchQueue.global(qos: .background).sync {
|
||||||
@@ -39,53 +34,61 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
return WindowGroup {
|
WindowGroup {
|
||||||
ContentView(
|
// contentAccessAuthenticationExtended has to be passed to ContentView on view initialization,
|
||||||
doAuthenticate: $doAuthenticate,
|
// so that it's computed by the time view renders, and not on event after rendering
|
||||||
userAuthorized: $userAuthorized,
|
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
|
||||||
canConnectCall: $canConnectCall,
|
|
||||||
lastSuccessfulUnlock: $lastSuccessfulUnlock,
|
|
||||||
showInitializationView: $showInitializationView
|
|
||||||
)
|
|
||||||
.environmentObject(chatModel)
|
.environmentObject(chatModel)
|
||||||
|
.environmentObject(AppTheme.shared)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
logger.debug("ContentView.onOpenURL: \(url)")
|
logger.debug("ContentView.onOpenURL: \(url)")
|
||||||
chatModel.appOpenUrl = url
|
chatModel.appOpenUrl = url
|
||||||
}
|
}
|
||||||
.onAppear() {
|
.onAppear() {
|
||||||
showInitializationView = true
|
// Present screen for continue migration if it wasn't finished yet
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
if chatModel.migrationState != nil {
|
||||||
initChatAndMigrate()
|
// It's important, otherwise, user may be locked in undefined state
|
||||||
|
onboardingStageDefault.set(.step1_SimpleXInfo)
|
||||||
|
chatModel.onboardingStage = onboardingStageDefault.get()
|
||||||
|
} else if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||||
|
initChatAndMigrate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { phase in
|
||||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case .background:
|
case .background:
|
||||||
|
// --- authentication
|
||||||
|
// see ContentView .onChange(of: scenePhase) for remaining authentication logic
|
||||||
|
if chatModel.contentViewAccessAuthenticated {
|
||||||
|
enteredBackgroundAuthenticated = ProcessInfo.processInfo.systemUptime
|
||||||
|
}
|
||||||
|
chatModel.contentViewAccessAuthenticated = false
|
||||||
|
// authentication ---
|
||||||
|
|
||||||
if CallController.useCallKit() && chatModel.activeCall != nil {
|
if CallController.useCallKit() && chatModel.activeCall != nil {
|
||||||
CallController.shared.shouldSuspendChat = true
|
CallController.shared.shouldSuspendChat = true
|
||||||
} else {
|
} else {
|
||||||
suspendChat()
|
suspendChat()
|
||||||
BGManager.shared.schedule()
|
BGManager.shared.schedule()
|
||||||
}
|
}
|
||||||
if userAuthorized == true {
|
|
||||||
enteredBackground = ProcessInfo.processInfo.systemUptime
|
|
||||||
}
|
|
||||||
doAuthenticate = false
|
|
||||||
canConnectCall = false
|
|
||||||
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
|
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
|
||||||
case .active:
|
case .active:
|
||||||
CallController.shared.shouldSuspendChat = false
|
CallController.shared.shouldSuspendChat = false
|
||||||
let appState = appStateGroupDefault.get()
|
let appState = AppChatState.shared.value
|
||||||
startChatAndActivate()
|
|
||||||
if appState.inactive && chatModel.chatRunning == true {
|
if appState != .stopped {
|
||||||
updateChats()
|
startChatAndActivate {
|
||||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
if appState.inactive && chatModel.chatRunning == true {
|
||||||
updateCallInvitations()
|
updateChats()
|
||||||
|
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||||
|
updateCallInvitations()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
doAuthenticate = authenticationExpired()
|
|
||||||
canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently()
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -103,12 +106,12 @@ struct SimpleXApp: App {
|
|||||||
if legacyDatabase, case .documents = dbContainerGroupDefault.get() {
|
if legacyDatabase, case .documents = dbContainerGroupDefault.get() {
|
||||||
dbContainerGroupDefault.set(.documents)
|
dbContainerGroupDefault.set(.documents)
|
||||||
setMigrationState(.offer)
|
setMigrationState(.offer)
|
||||||
logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath(), privacy: .public)*.db")
|
logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath())*.db")
|
||||||
} else {
|
} else {
|
||||||
dbContainerGroupDefault.set(.group)
|
dbContainerGroupDefault.set(.group)
|
||||||
setMigrationState(.ready)
|
setMigrationState(.ready)
|
||||||
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db")
|
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath())*.db")
|
||||||
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present")
|
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not") present")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,22 +121,14 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func authenticationExpired() -> Bool {
|
private func authenticationExpired() -> Bool {
|
||||||
if let enteredBackground = enteredBackground {
|
if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated {
|
||||||
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
|
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
|
||||||
return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay
|
return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay
|
||||||
} else {
|
} else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unlockedRecently() -> Bool {
|
|
||||||
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
|
||||||
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateChats() {
|
private func updateChats() {
|
||||||
do {
|
do {
|
||||||
let chats = try apiGetChats()
|
let chats = try apiGetChats()
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
//
|
||||||
|
// Theme.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 14.06.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
|
||||||
|
var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } }
|
||||||
|
var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 }
|
||||||
|
|
||||||
|
func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight }
|
||||||
|
|
||||||
|
class AppTheme: ObservableObject, Equatable {
|
||||||
|
static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper)
|
||||||
|
|
||||||
|
var name: String
|
||||||
|
var base: DefaultTheme
|
||||||
|
@ObservedObject var colors: Colors
|
||||||
|
@ObservedObject var appColors: AppColors
|
||||||
|
@ObservedObject var wallpaper: AppWallpaper
|
||||||
|
|
||||||
|
init(name: String, base: DefaultTheme, colors: Colors, appColors: AppColors, wallpaper: AppWallpaper) {
|
||||||
|
self.name = name
|
||||||
|
self.base = base
|
||||||
|
self.colors = colors
|
||||||
|
self.appColors = appColors
|
||||||
|
self.wallpaper = wallpaper
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: AppTheme, rhs: AppTheme) -> Bool {
|
||||||
|
lhs.name == rhs.name &&
|
||||||
|
lhs.colors == rhs.colors &&
|
||||||
|
lhs.appColors == rhs.appColors &&
|
||||||
|
lhs.wallpaper == rhs.wallpaper
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFromCurrentColors() {
|
||||||
|
objectWillChange.send()
|
||||||
|
name = CurrentColors.name
|
||||||
|
base = CurrentColors.base
|
||||||
|
colors.updateColorsFrom(CurrentColors.colors)
|
||||||
|
appColors.updateColorsFrom(CurrentColors.appColors)
|
||||||
|
wallpaper.updateWallpaperFrom(CurrentColors.wallpaper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ThemedBackground: ViewModifier {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
var grouped: Bool = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background(
|
||||||
|
theme.base == DefaultTheme.SIMPLEX
|
||||||
|
? LinearGradient(
|
||||||
|
colors: [
|
||||||
|
grouped
|
||||||
|
? theme.colors.background.lighter(0.4).asGroupedBackground(theme.base.mode)
|
||||||
|
: theme.colors.background.lighter(0.4),
|
||||||
|
grouped
|
||||||
|
? theme.colors.background.darker(0.4).asGroupedBackground(theme.base.mode)
|
||||||
|
: theme.colors.background.darker(0.4)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
: LinearGradient(
|
||||||
|
colors: [],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
theme.base == DefaultTheme.SIMPLEX
|
||||||
|
? Color.clear
|
||||||
|
: grouped
|
||||||
|
? theme.colors.background.asGroupedBackground(theme.base.mode)
|
||||||
|
: theme.colors.background
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemInDarkThemeCurrently: Bool {
|
||||||
|
return UITraitCollection.current.userInterfaceStyle == .dark
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactOnDarkThemeChanges() {
|
||||||
|
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == systemInDarkThemeCurrently {
|
||||||
|
// Change active colors from light to dark and back based on system theme
|
||||||
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ThemeWallpaper {
|
||||||
|
public func importFromString() -> ThemeWallpaper {
|
||||||
|
if preset == nil, let image {
|
||||||
|
// Need to save image from string and to save its path
|
||||||
|
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||||
|
let parsed = UIImage(data: data),
|
||||||
|
let filename = saveWallpaperFile(image: parsed) {
|
||||||
|
var copy = self
|
||||||
|
copy.image = nil
|
||||||
|
copy.imageFile = filename
|
||||||
|
return copy
|
||||||
|
} else {
|
||||||
|
return ThemeWallpaper()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withFilledWallpaperBase64() -> ThemeWallpaper {
|
||||||
|
let aw = toAppWallpaper()
|
||||||
|
let type = aw.type
|
||||||
|
let preset: String? = if case let WallpaperType.preset(filename, _) = type { filename } else { nil }
|
||||||
|
let scale: Float? = if case let WallpaperType.preset(_, scale) = type { scale } else { if case let WallpaperType.image(_, scale, _) = type { scale } else { 1.0 } }
|
||||||
|
let scaleType: WallpaperScaleType? = if case let WallpaperType.image(_, _, scaleType) = type { scaleType } else { nil }
|
||||||
|
let image: String? = if case WallpaperType.image = type, let image = type.uiImage { resizeImageToStrSize(image, maxDataSize: 5_000_000) } else { nil }
|
||||||
|
return ThemeWallpaper (
|
||||||
|
preset: preset,
|
||||||
|
scale: scale,
|
||||||
|
scaleType: scaleType,
|
||||||
|
background: aw.background?.toReadableHex(),
|
||||||
|
tint: aw.tint?.toReadableHex(),
|
||||||
|
image: image,
|
||||||
|
imageFile: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ThemeModeOverride {
|
||||||
|
func removeSameColors(_ base: DefaultTheme, colorsToCompare tc: ThemeColors) -> ThemeModeOverride {
|
||||||
|
let wallpaperType = WallpaperType.from(wallpaper) ?? WallpaperType.empty
|
||||||
|
let w: ThemeWallpaper
|
||||||
|
switch wallpaperType {
|
||||||
|
case let WallpaperType.preset(filename, scale):
|
||||||
|
let p = PresetWallpaper.from(filename)
|
||||||
|
w = ThemeWallpaper(
|
||||||
|
preset: filename,
|
||||||
|
scale: scale ?? wallpaper?.scale,
|
||||||
|
scaleType: nil,
|
||||||
|
background: p?.background[base]?.toReadableHex(),
|
||||||
|
tint: p?.tint[base]?.toReadableHex(),
|
||||||
|
image: nil,
|
||||||
|
imageFile: nil
|
||||||
|
)
|
||||||
|
case WallpaperType.image:
|
||||||
|
w = ThemeWallpaper(
|
||||||
|
preset: nil,
|
||||||
|
scale: nil,
|
||||||
|
scaleType: WallpaperScaleType.fill,
|
||||||
|
background: Color.clear.toReadableHex(),
|
||||||
|
tint: Color.clear.toReadableHex(),
|
||||||
|
image: nil,
|
||||||
|
imageFile: nil
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
w = ThemeWallpaper()
|
||||||
|
}
|
||||||
|
let wallpaper: ThemeWallpaper? = if let wallpaper {
|
||||||
|
ThemeWallpaper(
|
||||||
|
preset: wallpaper.preset,
|
||||||
|
scale: wallpaper.scale != w.scale ? wallpaper.scale : nil,
|
||||||
|
scaleType: wallpaper.scaleType != w.scaleType ? wallpaper.scaleType : nil,
|
||||||
|
background: wallpaper.background != w.background ? wallpaper.background : nil,
|
||||||
|
tint: wallpaper.tint != w.tint ? wallpaper.tint : nil,
|
||||||
|
image: wallpaper.image,
|
||||||
|
imageFile: wallpaper.imageFile
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
return ThemeModeOverride(
|
||||||
|
mode: self.mode,
|
||||||
|
colors: ThemeColors(
|
||||||
|
primary: colors.primary != tc.primary ? colors.primary : nil,
|
||||||
|
primaryVariant: colors.primaryVariant != tc.primaryVariant ? colors.primaryVariant : nil,
|
||||||
|
secondary: colors.secondary != tc.secondary ? colors.secondary : nil,
|
||||||
|
secondaryVariant: colors.secondaryVariant != tc.secondaryVariant ? colors.secondaryVariant : nil,
|
||||||
|
background: colors.background != tc.background ? colors.background : nil,
|
||||||
|
surface: colors.surface != tc.surface ? colors.surface : nil,
|
||||||
|
title: colors.title != tc.title ? colors.title : nil,
|
||||||
|
primaryVariant2: colors.primaryVariant2 != tc.primaryVariant2 ? colors.primary : nil,
|
||||||
|
sentMessage: colors.sentMessage != tc.sentMessage ? colors.sentMessage : nil,
|
||||||
|
sentQuote: colors.sentQuote != tc.sentQuote ? colors.sentQuote : nil,
|
||||||
|
receivedMessage: colors.receivedMessage != tc.receivedMessage ? colors.receivedMessage : nil,
|
||||||
|
receivedQuote: colors.receivedQuote != tc.receivedQuote ? colors.receivedQuote : nil
|
||||||
|
),
|
||||||
|
wallpaper: wallpaper
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
//
|
||||||
|
// ThemeManager.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 03.06.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
class ThemeManager {
|
||||||
|
struct ActiveTheme: Equatable {
|
||||||
|
let name: String
|
||||||
|
let base: DefaultTheme
|
||||||
|
let colors: Colors
|
||||||
|
let appColors: AppColors
|
||||||
|
var wallpaper: AppWallpaper = AppWallpaper(background: nil, tint: nil, type: .empty)
|
||||||
|
|
||||||
|
func toAppTheme() -> AppTheme {
|
||||||
|
AppTheme(name: name, base: base, colors: colors, appColors: appColors, wallpaper: wallpaper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func systemDarkThemeColors() -> (Colors, DefaultTheme) {
|
||||||
|
switch systemDarkThemeDefault.get() {
|
||||||
|
case DefaultTheme.DARK.themeName: (DarkColorPalette, DefaultTheme.DARK)
|
||||||
|
case DefaultTheme.SIMPLEX.themeName: (SimplexColorPalette, DefaultTheme.SIMPLEX)
|
||||||
|
case DefaultTheme.BLACK.themeName: (BlackColorPalette, DefaultTheme.BLACK)
|
||||||
|
default: (SimplexColorPalette, DefaultTheme.SIMPLEX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nonSystemThemeName() -> String {
|
||||||
|
let themeName = currentThemeDefault.get()
|
||||||
|
return if themeName != DefaultTheme.SYSTEM_THEME_NAME {
|
||||||
|
themeName
|
||||||
|
} else {
|
||||||
|
systemInDarkThemeCurrently ? systemDarkThemeDefault.get() : DefaultTheme.LIGHT.themeName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? {
|
||||||
|
let nonSystemThemeName = nonSystemThemeName()
|
||||||
|
let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName]
|
||||||
|
return appSettingsTheme.getTheme(defaultThemeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultActiveTheme(_ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ThemeModeOverride {
|
||||||
|
let perUserTheme = !CurrentColors.colors.isLight ? perUserTheme?.dark : perUserTheme?.light
|
||||||
|
if let perUserTheme {
|
||||||
|
return perUserTheme
|
||||||
|
}
|
||||||
|
let defaultTheme = defaultActiveTheme(appSettingsTheme)
|
||||||
|
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
|
||||||
|
let themeName = currentThemeDefault.get()
|
||||||
|
let nonSystemThemeName = nonSystemThemeName()
|
||||||
|
let defaultTheme = defaultActiveTheme(appSettingsTheme)
|
||||||
|
|
||||||
|
let baseTheme = switch nonSystemThemeName {
|
||||||
|
case DefaultTheme.LIGHT.themeName: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
|
||||||
|
case DefaultTheme.DARK.themeName: ActiveTheme(name: DefaultTheme.DARK.themeName, base: DefaultTheme.DARK, colors: DarkColorPalette.clone(), appColors: DarkColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.DARK)))
|
||||||
|
case DefaultTheme.SIMPLEX.themeName: ActiveTheme(name: DefaultTheme.SIMPLEX.themeName, base: DefaultTheme.SIMPLEX, colors: SimplexColorPalette.clone(), appColors: SimplexColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.SIMPLEX)))
|
||||||
|
case DefaultTheme.BLACK.themeName: ActiveTheme(name: DefaultTheme.BLACK.themeName, base: DefaultTheme.BLACK, colors: BlackColorPalette.clone(), appColors: BlackColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.BLACK)))
|
||||||
|
default: ActiveTheme(name: DefaultTheme.LIGHT.themeName, base: DefaultTheme.LIGHT, colors: LightColorPalette.clone(), appColors: LightColorPaletteApp.clone(), wallpaper: AppWallpaper(background: nil, tint: nil, type: PresetWallpaper.school.toType(DefaultTheme.LIGHT)))
|
||||||
|
}
|
||||||
|
|
||||||
|
let perUserTheme = baseTheme.colors.isLight ? perUserTheme?.light : perUserTheme?.dark
|
||||||
|
let theme = appSettingsTheme.sameTheme(themeOverridesForType ?? perChatTheme?.type ?? perUserTheme?.type ?? defaultTheme?.wallpaper?.toAppWallpaper().type, nonSystemThemeName) ?? defaultTheme
|
||||||
|
|
||||||
|
if theme == nil && perUserTheme == nil && perChatTheme == nil && themeOverridesForType == nil {
|
||||||
|
return ActiveTheme(name: themeName, base: baseTheme.base, colors: baseTheme.colors, appColors: baseTheme.appColors, wallpaper: baseTheme.wallpaper)
|
||||||
|
}
|
||||||
|
let presetWallpaperTheme: ThemeColors? = if let themeOverridesForType, case let WallpaperType.preset(filename, _) = themeOverridesForType {
|
||||||
|
PresetWallpaper.from(filename)?.colors[baseTheme.base]
|
||||||
|
} else if let wallpaper = perChatTheme?.wallpaper {
|
||||||
|
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
|
||||||
|
} else if let wallpaper = perUserTheme?.wallpaper {
|
||||||
|
if let preset = wallpaper.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
|
||||||
|
} else {
|
||||||
|
if let preset = theme?.wallpaper?.preset { PresetWallpaper.from(preset)?.colors[baseTheme.base] } else { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
let themeOrEmpty = theme ?? ThemeOverrides(base: baseTheme.base)
|
||||||
|
let colors = themeOrEmpty.toColors(themeOrEmpty.base, perChatTheme?.colors, perUserTheme?.colors, presetWallpaperTheme)
|
||||||
|
return ActiveTheme(
|
||||||
|
name: themeName,
|
||||||
|
base: baseTheme.base,
|
||||||
|
colors: colors,
|
||||||
|
appColors: themeOrEmpty.toAppColors(themeOrEmpty.base, perChatTheme?.colors, perChatTheme?.type, perUserTheme?.colors, perUserTheme?.type, presetWallpaperTheme),
|
||||||
|
wallpaper: themeOrEmpty.toAppWallpaper(themeOverridesForType, perChatTheme, perUserTheme, colors.background)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides {
|
||||||
|
let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get())
|
||||||
|
let wType = current.wallpaper.type
|
||||||
|
let wBackground = current.wallpaper.background
|
||||||
|
let wTint = current.wallpaper.tint
|
||||||
|
let w: ThemeWallpaper? = if case WallpaperType.empty = wType {
|
||||||
|
nil
|
||||||
|
} else {
|
||||||
|
ThemeWallpaper.from(wType, wBackground?.toReadableHex(), wTint?.toReadableHex()).withFilledWallpaperBase64()
|
||||||
|
}
|
||||||
|
return ThemeOverrides(
|
||||||
|
themeId: "",
|
||||||
|
base: current.base,
|
||||||
|
colors: ThemeColors.from(current.colors, current.appColors),
|
||||||
|
wallpaper: w
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func applyTheme(_ theme: String) {
|
||||||
|
currentThemeDefault.set(theme)
|
||||||
|
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
AppTheme.shared.updateFromCurrentColors()
|
||||||
|
let tint = UIColor(CurrentColors.colors.primary)
|
||||||
|
if SceneDelegate.windowStatic?.tintColor != tint {
|
||||||
|
SceneDelegate.windowStatic?.tintColor = tint
|
||||||
|
}
|
||||||
|
// applyNavigationBarColors(CurrentColors.toAppTheme())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func adjustWindowStyle() {
|
||||||
|
let style = switch currentThemeDefault.get() {
|
||||||
|
case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light
|
||||||
|
case DefaultTheme.SYSTEM_THEME_NAME: UIUserInterfaceStyle.unspecified
|
||||||
|
default: UIUserInterfaceStyle.dark
|
||||||
|
}
|
||||||
|
if SceneDelegate.windowStatic?.overrideUserInterfaceStyle != style {
|
||||||
|
SceneDelegate.windowStatic?.overrideUserInterfaceStyle = style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// static func applyNavigationBarColors(_ theme: AppTheme) {
|
||||||
|
// let baseColors = switch theme.base {
|
||||||
|
// case DefaultTheme.LIGHT: LightColorPaletteApp
|
||||||
|
// case DefaultTheme.DARK: DarkColorPaletteApp
|
||||||
|
// case DefaultTheme.SIMPLEX: SimplexColorPaletteApp
|
||||||
|
// case DefaultTheme.BLACK: BlackColorPaletteApp
|
||||||
|
// }
|
||||||
|
// let isDefaultColor = baseColors.title == theme.appColors.title
|
||||||
|
//
|
||||||
|
// let title = UIColor(theme.appColors.title)
|
||||||
|
// if !isDefaultColor && UINavigationBar.appearance().titleTextAttributes?.first as? UIColor != title {
|
||||||
|
// UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: title]
|
||||||
|
// UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: title]
|
||||||
|
// } else {
|
||||||
|
// UINavigationBar.appearance().titleTextAttributes = nil
|
||||||
|
// UINavigationBar.appearance().largeTitleTextAttributes = nil
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
static func changeDarkTheme(_ theme: String) {
|
||||||
|
systemDarkThemeDefault.set(theme)
|
||||||
|
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
AppTheme.shared.updateFromCurrentColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||||
|
let nonSystemThemeName = baseTheme.themeName
|
||||||
|
let pref = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
let themeId = currentThemeIdsDefault.get()[nonSystemThemeName]
|
||||||
|
let prevValue = overrides.getTheme(themeId) ?? ThemeOverrides(base: baseTheme)
|
||||||
|
pref.set(overrides.replace(prevValue.withUpdatedColor(name, color?.toReadableHex())))
|
||||||
|
var themeIds = currentThemeIdsDefault.get()
|
||||||
|
themeIds[nonSystemThemeName] = prevValue.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func applyThemeColor(name: ThemeColor, color: Color? = nil, pref: Binding<ThemeModeOverride>) {
|
||||||
|
pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) {
|
||||||
|
let nonSystemThemeName = baseTheme.themeName
|
||||||
|
let pref = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
let theme = overrides.sameTheme(type, baseTheme.themeName)
|
||||||
|
var prevValue = theme ?? ThemeOverrides(base: baseTheme)
|
||||||
|
prevValue.wallpaper = if let type {
|
||||||
|
if case WallpaperType.empty = type {
|
||||||
|
nil as ThemeWallpaper?
|
||||||
|
} else {
|
||||||
|
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
pref.set(overrides.replace(prevValue))
|
||||||
|
var themeIds = currentThemeIdsDefault.get()
|
||||||
|
themeIds[nonSystemThemeName] = prevValue.themeId
|
||||||
|
currentThemeIdsDefault.set(themeIds)
|
||||||
|
applyTheme(nonSystemThemeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {
|
||||||
|
let overrides = themeOverridesDefault.get()
|
||||||
|
let sameWallpaper: ThemeWallpaper? = if let wallpaper = lowerLevelOverride?.wallpaper, lowerLevelOverride?.type?.sameType(type) == true {
|
||||||
|
wallpaper
|
||||||
|
} else {
|
||||||
|
overrides.sameTheme(type, CurrentColors.base.themeName)?.wallpaper
|
||||||
|
}
|
||||||
|
guard let sameWallpaper else {
|
||||||
|
if let type {
|
||||||
|
var w: ThemeWallpaper = ThemeWallpaper.from(type, nil, nil)
|
||||||
|
w.scale = nil
|
||||||
|
w.scaleType = nil
|
||||||
|
w.background = nil
|
||||||
|
w.tint = nil
|
||||||
|
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: w)
|
||||||
|
} else {
|
||||||
|
// Make an empty wallpaper to override any top level ones
|
||||||
|
pref.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode, wallpaper: ThemeWallpaper())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var type = sameWallpaper.toAppWallpaper().type
|
||||||
|
if case let WallpaperType.image(filename, scale, scaleType) = type, sameWallpaper.imageFile == filename {
|
||||||
|
// same image file. Needs to be copied first in order to be able to remove the file once it's not needed anymore without affecting main theme override
|
||||||
|
if let filename = saveWallpaperFile(url: getWallpaperFilePath(filename)) {
|
||||||
|
type = WallpaperType.image(filename, scale, scaleType)
|
||||||
|
} else {
|
||||||
|
logger.error("Error while copying wallpaper from global overrides to chat overrides")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var prevValue = pref.wrappedValue
|
||||||
|
var w = ThemeWallpaper.from(type, nil, nil)
|
||||||
|
w.scale = nil
|
||||||
|
w.scaleType = nil
|
||||||
|
w.background = nil
|
||||||
|
w.tint = nil
|
||||||
|
prevValue.colors = ThemeColors()
|
||||||
|
prevValue.wallpaper = w
|
||||||
|
pref.wrappedValue = prevValue
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
static func applyWallpaper(_ type: WallpaperType?, _ pref: Binding<ThemeModeOverride>) {
|
||||||
|
var prevValue = pref.wrappedValue
|
||||||
|
prevValue.wallpaper = if let type {
|
||||||
|
ThemeWallpaper.from(type, prevValue.wallpaper?.background, prevValue.wallpaper?.tint)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
pref.wrappedValue = prevValue
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||||
|
let wallpaper = theme.wallpaper?.importFromString()
|
||||||
|
let nonSystemThemeName = theme.base.themeName
|
||||||
|
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
var prevValue = overrides.getTheme(nil, wallpaper?.toAppWallpaper().type, theme.base) ?? ThemeOverrides(base: theme.base)
|
||||||
|
if let imageFile = prevValue.wallpaper?.imageFile {
|
||||||
|
try? FileManager.default.removeItem(at: getWallpaperFilePath(imageFile))
|
||||||
|
}
|
||||||
|
prevValue.base = theme.base
|
||||||
|
prevValue.colors = theme.colors
|
||||||
|
prevValue.wallpaper = wallpaper
|
||||||
|
pref.set(overrides.replace(prevValue))
|
||||||
|
currentThemeDefault.set(nonSystemThemeName)
|
||||||
|
var currentThemeIds = currentThemeIdsDefault.get()
|
||||||
|
currentThemeIds[nonSystemThemeName] = prevValue.themeId
|
||||||
|
currentThemeIdsDefault.set(currentThemeIds)
|
||||||
|
applyTheme(nonSystemThemeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||||
|
let nonSystemThemeName = nonSystemThemeName()
|
||||||
|
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
|
||||||
|
let overrides = pref.get()
|
||||||
|
guard let themeId = currentThemeIdsDefault.get()[nonSystemThemeName],
|
||||||
|
var prevValue = overrides.getTheme(themeId)
|
||||||
|
else { return }
|
||||||
|
prevValue.colors = ThemeColors()
|
||||||
|
prevValue.wallpaper?.background = nil
|
||||||
|
prevValue.wallpaper?.tint = nil
|
||||||
|
pref.set(overrides.replace(prevValue))
|
||||||
|
applyTheme(currentThemeDefault.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resetAllThemeColors(_ pref: Binding<ThemeModeOverride>) {
|
||||||
|
var prevValue = pref.wrappedValue
|
||||||
|
prevValue.colors = ThemeColors()
|
||||||
|
prevValue.wallpaper?.background = nil
|
||||||
|
prevValue.wallpaper?.tint = nil
|
||||||
|
pref.wrappedValue = prevValue
|
||||||
|
}
|
||||||
|
|
||||||
|
static func removeTheme(_ themeId: String?) {
|
||||||
|
var themes = themeOverridesDefault.get().map { $0 }
|
||||||
|
themes.removeAll(where: { $0.themeId == themeId })
|
||||||
|
themeOverridesDefault.set(themes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,70 +9,84 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
import WebKit
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
struct ActiveCallView: View {
|
struct ActiveCallView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@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 activeCall: WebRTCClient.Call? = nil
|
||||||
@State private var localRendererAspectRatio: CGFloat? = nil
|
@State private var localRendererAspectRatio: CGFloat? = nil
|
||||||
@Binding var canConnectCall: Bool
|
@Binding var canConnectCall: Bool
|
||||||
|
@State var prevColorScheme: ColorScheme = .dark
|
||||||
|
@State var pipShown = false
|
||||||
|
@State var wasConnected = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .topLeading) {
|
||||||
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
|
ZStack(alignment: .bottom) {
|
||||||
GeometryReader { g in
|
if let client = client, [call.peerMedia, call.localMedia].contains(.video), activeCall != nil {
|
||||||
let width = g.size.width * 0.3
|
GeometryReader { g in
|
||||||
ZStack(alignment: .topTrailing) {
|
let width = g.size.width * 0.3
|
||||||
CallViewRemote(client: client, activeCall: $activeCall)
|
ZStack(alignment: .topTrailing) {
|
||||||
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio)
|
CallViewRemote(client: client, activeCall: $activeCall, activeCallViewIsCollapsed: $m.activeCallViewIsCollapsed, pipShown: $pipShown)
|
||||||
.cornerRadius(10)
|
CallViewLocal(client: client, activeCall: $activeCall, localRendererAspectRatio: $localRendererAspectRatio, pipShown: $pipShown)
|
||||||
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
|
.cornerRadius(10)
|
||||||
.padding([.top, .trailing], 17)
|
.frame(width: width, height: width / (localRendererAspectRatio ?? 1))
|
||||||
|
.padding([.top, .trailing], 17)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.primary.opacity(0.000001))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if let call = m.activeCall, let client = client, (!pipShown || !call.supportsVideo) {
|
||||||
if let call = m.activeCall, let client = client {
|
ActiveCallOverlay(call: call, client: client)
|
||||||
ActiveCallOverlay(call: call, client: client)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.allowsHitTesting(!m.activeCallViewIsCollapsed)
|
||||||
|
.opacity(m.activeCallViewIsCollapsed ? 0 : 1)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)")
|
logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase)), canConnectCall \(canConnectCall)")
|
||||||
AppDelegate.keepScreenOn(true)
|
AppDelegate.keepScreenOn(true)
|
||||||
createWebRTCClient()
|
createWebRTCClient()
|
||||||
dismissAllSheets()
|
dismissAllSheets()
|
||||||
|
hideKeyboard()
|
||||||
|
prevColorScheme = colorScheme
|
||||||
}
|
}
|
||||||
.onChange(of: canConnectCall) { _ in
|
.onChange(of: canConnectCall) { _ in
|
||||||
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)")
|
logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall)")
|
||||||
createWebRTCClient()
|
createWebRTCClient()
|
||||||
}
|
}
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
logger.debug("ActiveCallView: disappear")
|
logger.debug("ActiveCallView: disappear")
|
||||||
|
Task { await m.callCommand.setClient(nil) }
|
||||||
AppDelegate.keepScreenOn(false)
|
AppDelegate.keepScreenOn(false)
|
||||||
client?.endCall()
|
client?.endCall()
|
||||||
|
CallSoundsPlayer.shared.stop()
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.soloAmbient)
|
||||||
|
if (wasConnected) {
|
||||||
|
CallSoundsPlayer.shared.vibrate(long: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: m.callCommand) { _ in sendCommandToClient()}
|
.background(m.activeCallViewIsCollapsed ? .clear : .black)
|
||||||
.background(.black)
|
// Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(m.activeCallViewIsCollapsed || CallController.useCallKit() ? prevColorScheme : .dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createWebRTCClient() {
|
private func createWebRTCClient() {
|
||||||
if client == nil && canConnectCall {
|
if client == nil && canConnectCall {
|
||||||
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
|
client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio)
|
||||||
sendCommandToClient()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendCommandToClient() {
|
|
||||||
if call == m.activeCall,
|
|
||||||
m.activeCall != nil,
|
|
||||||
let client = client,
|
|
||||||
let cmd = m.callCommand {
|
|
||||||
m.callCommand = nil
|
|
||||||
logger.debug("sendCallCommand: \(cmd.cmdType)")
|
|
||||||
Task {
|
Task {
|
||||||
await client.sendCallCommand(command: cmd)
|
await m.callCommand.setClient(client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,8 +94,8 @@ struct ActiveCallView: View {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func processRtcMessage(msg: WVAPIMessage) {
|
private func processRtcMessage(msg: WVAPIMessage) {
|
||||||
if call == m.activeCall,
|
if call == m.activeCall,
|
||||||
let call = m.activeCall,
|
let call = m.activeCall,
|
||||||
let client = client {
|
let client = client {
|
||||||
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):
|
||||||
@@ -96,12 +110,17 @@ struct ActiveCallView: View {
|
|||||||
call.callState = .invitationSent
|
call.callState = .invitationSent
|
||||||
call.localCapabilities = capabilities
|
call.localCapabilities = capabilities
|
||||||
}
|
}
|
||||||
|
if call.supportsVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() {
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||||
|
}
|
||||||
|
CallSoundsPlayer.shared.startConnectingCallSound()
|
||||||
|
activeCallWaitDeliveryReceipt()
|
||||||
}
|
}
|
||||||
case let .offer(offer, iceCandidates, capabilities):
|
case let .offer(offer, iceCandidates, capabilities):
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
try await apiSendCallOffer(call.contact, offer, iceCandidates,
|
||||||
media: call.localMedia, capabilities: capabilities)
|
media: call.localMedia, capabilities: capabilities)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("apiSendCallOffer \(responseError(error))")
|
logger.error("apiSendCallOffer \(responseError(error))")
|
||||||
}
|
}
|
||||||
@@ -119,6 +138,7 @@ struct ActiveCallView: View {
|
|||||||
}
|
}
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
call.callState = .negotiated
|
call.callState = .negotiated
|
||||||
|
CallSoundsPlayer.shared.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .ice(iceCandidates):
|
case let .ice(iceCandidates):
|
||||||
@@ -133,13 +153,19 @@ struct ActiveCallView: View {
|
|||||||
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState),
|
||||||
case .connected = callStatus {
|
case .connected = callStatus {
|
||||||
call.direction == .outgoing
|
call.direction == .outgoing
|
||||||
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil)
|
||||||
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
|
: CallController.shared.reportIncomingCall(call: call, connectedAt: nil)
|
||||||
call.callState = .connected
|
call.callState = .connected
|
||||||
|
call.connectedAt = .now
|
||||||
|
if !wasConnected {
|
||||||
|
CallSoundsPlayer.shared.vibrate(long: false)
|
||||||
|
wasConnected = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if state.connectionState == "closed" {
|
if state.connectionState == "closed" {
|
||||||
closeCallView(client)
|
closeCallView(client)
|
||||||
m.activeCall = nil
|
m.activeCall = nil
|
||||||
|
m.activeCallViewIsCollapsed = false
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -151,6 +177,11 @@ struct ActiveCallView: View {
|
|||||||
case let .connected(connectionInfo):
|
case let .connected(connectionInfo):
|
||||||
call.callState = .connected
|
call.callState = .connected
|
||||||
call.connectionInfo = connectionInfo
|
call.connectionInfo = connectionInfo
|
||||||
|
call.connectedAt = .now
|
||||||
|
if !wasConnected {
|
||||||
|
CallSoundsPlayer.shared.vibrate(long: false)
|
||||||
|
wasConnected = true
|
||||||
|
}
|
||||||
case .ended:
|
case .ended:
|
||||||
closeCallView(client)
|
closeCallView(client)
|
||||||
call.callState = .ended
|
call.callState = .ended
|
||||||
@@ -164,12 +195,31 @@ struct ActiveCallView: View {
|
|||||||
case .end:
|
case .end:
|
||||||
closeCallView(client)
|
closeCallView(client)
|
||||||
m.activeCall = nil
|
m.activeCall = nil
|
||||||
|
m.activeCallViewIsCollapsed = false
|
||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
case let .error(message):
|
case let .error(message):
|
||||||
logger.debug("ActiveCallView: command error: \(message)")
|
logger.debug("ActiveCallView: command error: \(message)")
|
||||||
|
AlertManager.shared.showAlert(Alert(title: Text("Error"), message: Text(message)))
|
||||||
case let .invalid(type):
|
case let .invalid(type):
|
||||||
logger.debug("ActiveCallView: invalid response: \(type)")
|
logger.debug("ActiveCallView: invalid response: \(type)")
|
||||||
|
AlertManager.shared.showAlert(Alert(title: Text("Invalid response"), message: Text(type)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activeCallWaitDeliveryReceipt() {
|
||||||
|
ChatReceiver.shared.messagesChannel = { msg in
|
||||||
|
guard let call = ChatModel.shared.activeCall, call.callState == .invitationSent else {
|
||||||
|
ChatReceiver.shared.messagesChannel = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if case let .chatItemStatusUpdated(_, msg) = msg,
|
||||||
|
msg.chatInfo.id == call.contact.id,
|
||||||
|
case .sndCall = msg.chatItem.content,
|
||||||
|
case .sndRcvd = msg.chatItem.meta.itemStatus {
|
||||||
|
CallSoundsPlayer.shared.startInCallSound()
|
||||||
|
ChatReceiver.shared.messagesChannel = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,12 +235,13 @@ struct ActiveCallOverlay: View {
|
|||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@ObservedObject var call: Call
|
@ObservedObject var call: Call
|
||||||
var client: WebRTCClient
|
var client: WebRTCClient
|
||||||
|
@ObservedObject private var deviceManager = CallAudioDeviceManager.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
switch call.localMedia {
|
switch call.localMedia {
|
||||||
case .video:
|
case .video:
|
||||||
callInfoView(call, .leading)
|
videoCallInfoView(call)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.opacity(0.8)
|
.opacity(0.8)
|
||||||
.padding()
|
.padding()
|
||||||
@@ -200,7 +251,16 @@ struct ActiveCallOverlay: View {
|
|||||||
HStack {
|
HStack {
|
||||||
toggleAudioButton()
|
toggleAudioButton()
|
||||||
Spacer()
|
Spacer()
|
||||||
Color.clear.frame(width: 40, height: 40)
|
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()
|
Spacer()
|
||||||
endCallButton()
|
endCallButton()
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -217,16 +277,23 @@ struct ActiveCallOverlay: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
||||||
case .audio:
|
case .audio:
|
||||||
VStack {
|
ZStack(alignment: .topLeading) {
|
||||||
ProfileImage(imageStr: call.contact.profile.image)
|
Button {
|
||||||
.scaledToFit()
|
chatModel.activeCallViewIsCollapsed = true
|
||||||
.frame(width: 192, height: 192)
|
} label: {
|
||||||
callInfoView(call, .center)
|
Label("Back", systemImage: "chevron.left")
|
||||||
|
.padding()
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
VStack {
|
||||||
|
ProfileImage(imageStr: call.contact.profile.image, size: 192)
|
||||||
|
audioCallInfoView(call)
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.opacity(0.8)
|
||||||
|
.padding()
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.foregroundColor(.white)
|
|
||||||
.opacity(0.8)
|
|
||||||
.padding()
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -234,34 +301,81 @@ struct ActiveCallOverlay: View {
|
|||||||
toggleAudioButton()
|
toggleAudioButton()
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
endCallButton()
|
endCallButton()
|
||||||
toggleSpeakerButton()
|
// Check if the only input is microphone. And in this case show toggle button,
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
// If there are more inputs, it probably means something like bluetooth headphones are available
|
||||||
|
// and in this case show iOS button for choosing different output.
|
||||||
|
// There is no way to get available outputs, only inputs
|
||||||
|
if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) {
|
||||||
|
toggleSpeakerButton()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
} else if call.hasMedia {
|
||||||
|
AudioDevicePicker()
|
||||||
|
.scaleEffect(2)
|
||||||
|
.frame(maxWidth: 50, maxHeight: 40)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
} else {
|
||||||
|
Color.clear.frame(width: 50, height: 40)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 60)
|
.padding(.bottom, 60)
|
||||||
.padding(.horizontal, 48)
|
.padding(.horizontal, 48)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.onAppear {
|
||||||
|
deviceManager.start()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
deviceManager.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View {
|
private func audioCallInfoView(_ call: Call) -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
Text(call.contact.chatViewName)
|
Text(call.contact.chatViewName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.font(.title)
|
.font(.title)
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
Group {
|
Group {
|
||||||
Text(call.callState.text)
|
Text(call.callState.text)
|
||||||
HStack {
|
HStack {
|
||||||
Text(call.encryptionStatus)
|
Text(call.encryptionStatus)
|
||||||
if let connInfo = call.connectionInfo {
|
if let connInfo = call.connectionInfo {
|
||||||
// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))")
|
|
||||||
Text("(") + Text(connInfo.text) + Text(")")
|
Text("(") + Text(connInfo.text) + Text(")")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func videoCallInfoView(_ call: Call) -> some View {
|
||||||
|
VStack {
|
||||||
|
Button {
|
||||||
|
chatModel.activeCallViewIsCollapsed = true
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .center, spacing: 16) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 10, height: 18)
|
||||||
|
Text(call.contact.chatViewName)
|
||||||
|
.lineLimit(1)
|
||||||
|
.font(.title)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Group {
|
||||||
|
Text(call.callState.text)
|
||||||
|
HStack {
|
||||||
|
Text(call.encryptionStatus)
|
||||||
|
if let connInfo = call.connectionInfo {
|
||||||
|
Text("(") + Text(connInfo.text) + Text(")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,12 +405,17 @@ struct ActiveCallOverlay: View {
|
|||||||
private func toggleSpeakerButton() -> some View {
|
private func toggleSpeakerButton() -> some View {
|
||||||
controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
|
controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") {
|
||||||
Task {
|
Task {
|
||||||
client.setSpeakerEnabledAndConfigureSession(!call.speakerEnabled)
|
let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
|
||||||
|
client.setSpeakerEnabledAndConfigureSession(!speakerEnabled)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
call.speakerEnabled = !call.speakerEnabled
|
call.speakerEnabled = !speakerEnabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
deviceManager.call = call
|
||||||
|
//call.speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleVideoButton() -> some View {
|
private func toggleVideoButton() -> some View {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// MPVolumeView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 24.04.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import AVKit
|
||||||
|
|
||||||
|
struct AudioDevicePicker: UIViewRepresentable {
|
||||||
|
func makeUIView(context: Context) -> some UIView {
|
||||||
|
let v = AVRoutePickerView(frame: .zero)
|
||||||
|
v.activeTintColor = .white
|
||||||
|
v.tintColor = .white
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// CallAudioDeviceManager.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Avently on 23.04.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
import AVKit
|
||||||
|
import WebRTC
|
||||||
|
|
||||||
|
class CallAudioDeviceManager: ObservableObject {
|
||||||
|
static let shared = CallAudioDeviceManager()
|
||||||
|
let audioSession: AVAudioSession
|
||||||
|
let nc = NotificationCenter.default
|
||||||
|
|
||||||
|
var call: Call?
|
||||||
|
var timer: Timer? = nil
|
||||||
|
|
||||||
|
// Actually, only one output
|
||||||
|
@Published var outputs: [AVAudioSessionPortDescription]
|
||||||
|
@Published var currentDevice: AVAudioSessionPortDescription? = nil
|
||||||
|
// All devices that can record audio (the ones that can play audio are not included)
|
||||||
|
@Published var availableInputs: [AVAudioSessionPortDescription] = []
|
||||||
|
|
||||||
|
|
||||||
|
init(_ audioSession: AVAudioSession? = nil) {
|
||||||
|
self.audioSession = audioSession ?? RTCAudioSession.sharedInstance().session
|
||||||
|
self.outputs = self.audioSession.currentRoute.outputs
|
||||||
|
self.availableInputs = self.audioSession.availableInputs ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadDevices() {
|
||||||
|
outputs = audioSession.currentRoute.outputs
|
||||||
|
currentDevice = audioSession.currentRoute.outputs.first
|
||||||
|
availableInputs = audioSession.availableInputs ?? []
|
||||||
|
call?.speakerEnabled = currentDevice?.portType == .builtInSpeaker
|
||||||
|
|
||||||
|
|
||||||
|
// Workaround situation:
|
||||||
|
// have bluetooth device connected, choosing speaker, disconnecting bluetooth device. In this case iOS will not post notification, so do it manually
|
||||||
|
timer?.invalidate()
|
||||||
|
if availableInputs.contains(where: { $0.portType != .builtInReceiver && $0.portType != .builtInSpeaker }) {
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { t in
|
||||||
|
self.reloadDevices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func audioCallback(notification: Notification) {
|
||||||
|
reloadDevices()
|
||||||
|
|
||||||
|
logger.debug("Changes in devices, current audio devices: \(String(describing: self.availableInputs.map({ $0.portType.rawValue }))), output: \(String(describing: self.currentDevice?.portType.rawValue))")
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
nc.addObserver(self, selector: #selector(audioCallback), name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
nc.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
|
timer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,7 +103,23 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
|
RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession)
|
||||||
RTCAudioSession.sharedInstance().isAudioEnabled = true
|
RTCAudioSession.sharedInstance().isAudioEnabled = true
|
||||||
do {
|
do {
|
||||||
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers)
|
let supportsVideo = ChatModel.shared.activeCall?.supportsVideo == true
|
||||||
|
if supportsVideo {
|
||||||
|
try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||||
|
} else {
|
||||||
|
try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||||
|
}
|
||||||
|
// Without any delay sound is not playing from speaker or external device in incoming call
|
||||||
|
Task {
|
||||||
|
for i in 0 ... 3 {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000)
|
||||||
|
if let preferred = audioSession.preferredInputDevice() {
|
||||||
|
await MainActor.run { try? audioSession.setPreferredInput(preferred) }
|
||||||
|
} else if supportsVideo {
|
||||||
|
await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.debug("audioSession category set")
|
logger.debug("audioSession category set")
|
||||||
try audioSession.setActive(true)
|
try audioSession.setActive(true)
|
||||||
logger.debug("audioSession activated")
|
logger.debug("audioSession activated")
|
||||||
@@ -130,7 +146,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
// The delay allows to accept the second call before suspending a chat
|
// The delay allows to accept the second call before suspending a chat
|
||||||
// see `.onChange(of: scenePhase)` in SimpleXApp
|
// see `.onChange(of: scenePhase)` in SimpleXApp
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat), privacy: .public)")
|
logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat))")
|
||||||
if ChatModel.shared.activeCall == nil && self?.shouldSuspendChat == true {
|
if ChatModel.shared.activeCall == nil && self?.shouldSuspendChat == true {
|
||||||
self?.shouldSuspendChat = false
|
self?.shouldSuspendChat = false
|
||||||
suspendChat()
|
suspendChat()
|
||||||
@@ -142,33 +158,46 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
|
|
||||||
@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, privacy: .public)")
|
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
||||||
logger.debug("CallController: did receive push with type \(type.rawValue, privacy: .public)")
|
logger.debug("CallController: did receive push with type \(type.rawValue)")
|
||||||
if type != .voIP {
|
if type != .voIP {
|
||||||
completion()
|
completion()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.debug("CallController: initializing chat")
|
if AppChatState.shared.value == .stopped {
|
||||||
if (!ChatModel.shared.chatInitialized) {
|
self.reportExpiredCall(payload: payload, completion)
|
||||||
initChatAndMigrate(refreshInvitations: false)
|
return
|
||||||
}
|
}
|
||||||
startChatAndActivate()
|
if (!ChatModel.shared.chatInitialized) {
|
||||||
shouldSuspendChat = true
|
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
|
// There are no invitations in the model, as it was processed by NSE
|
||||||
_ = try? justRefreshCallInvitations()
|
_ = try? justRefreshCallInvitations()
|
||||||
|
logger.debug("CallController: updated call invitations chat")
|
||||||
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
|
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
|
||||||
// Extract the call information from the push notification payload
|
// Extract the call information from the push notification payload
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
if let contactId = payload.dictionaryPayload["contactId"] as? String,
|
||||||
let invitation = m.callInvitations[contactId] {
|
let invitation = m.callInvitations[contactId] {
|
||||||
let update = cxCallUpdate(invitation: invitation)
|
let update = self.cxCallUpdate(invitation: invitation)
|
||||||
if let uuid = invitation.callkitUUID {
|
if let uuid = invitation.callkitUUID {
|
||||||
logger.debug("CallController: report pushkit call via CallKit")
|
logger.debug("CallController: report pushkit call via CallKit")
|
||||||
let update = cxCallUpdate(invitation: invitation)
|
let update = self.cxCallUpdate(invitation: invitation)
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -176,10 +205,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
completion()
|
completion()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
reportExpiredCall(update: update, completion)
|
self.reportExpiredCall(update: update, completion)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
reportExpiredCall(payload: payload, completion)
|
self.reportExpiredCall(payload: payload, completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +239,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)")
|
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))")
|
||||||
if CallController.useCallKit(), let uuid = invitation.callkitUUID {
|
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)
|
||||||
@@ -350,7 +379,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
|||||||
private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) {
|
private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) {
|
||||||
controller.request(CXTransaction(action: action)) { error in
|
controller.request(CXTransaction(action: action)) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription, privacy: .public)")
|
logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)")
|
||||||
} else {
|
} else {
|
||||||
logger.debug("CallController.requestTransaction requested transaction successfully")
|
logger.debug("CallController.requestTransaction requested transaction successfully")
|
||||||
onSuccess()
|
onSuccess()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class CallManager {
|
|||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
if let call = m.activeCall, call.callkitUUID == callUUID {
|
if let call = m.activeCall, call.callkitUUID == callUUID {
|
||||||
m.showCallView = true
|
m.showCallView = true
|
||||||
m.callCommand = .capabilities(media: call.localMedia)
|
Task { await m.callCommand.processCommand(.capabilities(media: call.localMedia)) }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -57,19 +57,21 @@ class CallManager {
|
|||||||
m.activeCall = call
|
m.activeCall = call
|
||||||
m.showCallView = true
|
m.showCallView = true
|
||||||
|
|
||||||
m.callCommand = .start(
|
Task {
|
||||||
|
await m.callCommand.processCommand(.start(
|
||||||
media: invitation.callType.media,
|
media: invitation.callType.media,
|
||||||
aesKey: invitation.sharedKey,
|
aesKey: invitation.sharedKey,
|
||||||
iceServers: iceServers,
|
iceServers: iceServers,
|
||||||
relay: useRelay
|
relay: useRelay
|
||||||
)
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
|
func enableMedia(media: CallMediaType, enable: Bool, callUUID: UUID) -> Bool {
|
||||||
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
m.callCommand = .media(media: media, enable: enable)
|
Task { await m.callCommand.processCommand(.media(media: media, enable: enable)) }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -90,15 +92,19 @@ class CallManager {
|
|||||||
if case .ended = call.callState {
|
if case .ended = call.callState {
|
||||||
logger.debug("CallManager.endCall: call ended")
|
logger.debug("CallManager.endCall: call ended")
|
||||||
m.activeCall = nil
|
m.activeCall = nil
|
||||||
|
m.activeCallViewIsCollapsed = false
|
||||||
m.showCallView = false
|
m.showCallView = false
|
||||||
completed()
|
completed()
|
||||||
} else {
|
} else {
|
||||||
logger.debug("CallManager.endCall: ending call...")
|
logger.debug("CallManager.endCall: ending call...")
|
||||||
m.callCommand = .end
|
|
||||||
m.activeCall = nil
|
|
||||||
m.showCallView = false
|
|
||||||
completed()
|
|
||||||
Task {
|
Task {
|
||||||
|
await m.callCommand.processCommand(.end)
|
||||||
|
await MainActor.run {
|
||||||
|
m.activeCall = nil
|
||||||
|
m.activeCallViewIsCollapsed = false
|
||||||
|
m.showCallView = false
|
||||||
|
completed()
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try await apiEndCall(call.contact)
|
try await apiEndCall(call.contact)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -6,14 +6,20 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebRTC
|
import WebRTC
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
import AVKit
|
||||||
|
|
||||||
struct CallViewRemote: UIViewRepresentable {
|
struct CallViewRemote: UIViewRepresentable {
|
||||||
var client: WebRTCClient
|
var client: WebRTCClient
|
||||||
var activeCall: Binding<WebRTCClient.Call?>
|
var activeCall: Binding<WebRTCClient.Call?>
|
||||||
|
@State var enablePip: (Bool) -> Void = {_ in }
|
||||||
|
@Binding var activeCallViewIsCollapsed: Bool
|
||||||
|
@Binding var pipShown: Bool
|
||||||
|
|
||||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>) {
|
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, activeCallViewIsCollapsed: Binding<Bool>, pipShown: Binding<Bool>) {
|
||||||
self.client = client
|
self.client = client
|
||||||
self.activeCall = activeCall
|
self.activeCall = activeCall
|
||||||
|
self._activeCallViewIsCollapsed = activeCallViewIsCollapsed
|
||||||
|
self._pipShown = pipShown
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
func makeUIView(context: Context) -> UIView {
|
||||||
@@ -23,12 +29,120 @@ struct CallViewRemote: UIViewRepresentable {
|
|||||||
remoteRenderer.videoContentMode = .scaleAspectFill
|
remoteRenderer.videoContentMode = .scaleAspectFill
|
||||||
client.addRemoteRenderer(call, remoteRenderer)
|
client.addRemoteRenderer(call, remoteRenderer)
|
||||||
addSubviewAndResize(remoteRenderer, into: view)
|
addSubviewAndResize(remoteRenderer, into: view)
|
||||||
|
|
||||||
|
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||||
|
makeViewWithRTCRenderer(call, remoteRenderer, view, context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeViewWithRTCRenderer(_ call: WebRTCClient.Call, _ remoteRenderer: RTCMTLVideoView, _ view: UIView, _ context: Context) {
|
||||||
|
let pipRemoteRenderer = RTCMTLVideoView(frame: view.frame)
|
||||||
|
pipRemoteRenderer.videoContentMode = .scaleAspectFill
|
||||||
|
|
||||||
|
let pipVideoCallViewController = AVPictureInPictureVideoCallViewController()
|
||||||
|
pipVideoCallViewController.preferredContentSize = CGSize(width: 1080, height: 1920)
|
||||||
|
addSubviewAndResize(pipRemoteRenderer, into: pipVideoCallViewController.view)
|
||||||
|
let pipContentSource = AVPictureInPictureController.ContentSource(
|
||||||
|
activeVideoCallSourceView: view,
|
||||||
|
contentViewController: pipVideoCallViewController
|
||||||
|
)
|
||||||
|
|
||||||
|
let pipController = AVPictureInPictureController(contentSource: pipContentSource)
|
||||||
|
pipController.canStartPictureInPictureAutomaticallyFromInline = true
|
||||||
|
pipController.delegate = context.coordinator
|
||||||
|
context.coordinator.pipController = pipController
|
||||||
|
context.coordinator.willShowHide = { show in
|
||||||
|
if show {
|
||||||
|
client.addRemoteRenderer(call, pipRemoteRenderer)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
activeCallViewIsCollapsed = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||||
|
activeCallViewIsCollapsed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.coordinator.didShowHide = { show in
|
||||||
|
if show {
|
||||||
|
remoteRenderer.isHidden = true
|
||||||
|
} else {
|
||||||
|
client.removeRemoteRenderer(call, pipRemoteRenderer)
|
||||||
|
remoteRenderer.isHidden = false
|
||||||
|
}
|
||||||
|
pipShown = show
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
enablePip = { enable in
|
||||||
|
if enable != pipShown /* pipController.isPictureInPictureActive */ {
|
||||||
|
if enable {
|
||||||
|
pipController.startPictureInPicture()
|
||||||
|
} else {
|
||||||
|
pipController.stopPictureInPicture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
func updateUIView(_ view: UIView, context: Context) {
|
func updateUIView(_ view: UIView, context: Context) {
|
||||||
logger.debug("CallView.updateUIView remote")
|
logger.debug("CallView.updateUIView remote")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if activeCallViewIsCollapsed != pipShown {
|
||||||
|
enablePip(activeCallViewIsCollapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinator
|
||||||
|
class Coordinator: NSObject, AVPictureInPictureControllerDelegate {
|
||||||
|
var pipController: AVPictureInPictureController? = nil
|
||||||
|
var willShowHide: (Bool) -> Void = { _ in }
|
||||||
|
var didShowHide: (Bool) -> Void = { _ in }
|
||||||
|
|
||||||
|
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
willShowHide(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
didShowHide(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
||||||
|
logger.error("PiP failed to start: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
willShowHide(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||||
|
didShowHide(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
pipController?.stopPictureInPicture()
|
||||||
|
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||||
|
pipController?.contentSource = nil
|
||||||
|
pipController?.delegate = nil
|
||||||
|
pipController = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SampleBufferVideoCallView: UIView {
|
||||||
|
override class var layerClass: AnyClass {
|
||||||
|
get { return AVSampleBufferDisplayLayer.self }
|
||||||
|
}
|
||||||
|
|
||||||
|
var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer {
|
||||||
|
return layer as! AVSampleBufferDisplayLayer
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,11 +150,14 @@ struct CallViewLocal: UIViewRepresentable {
|
|||||||
var client: WebRTCClient
|
var client: WebRTCClient
|
||||||
var activeCall: Binding<WebRTCClient.Call?>
|
var activeCall: Binding<WebRTCClient.Call?>
|
||||||
var localRendererAspectRatio: Binding<CGFloat?>
|
var localRendererAspectRatio: Binding<CGFloat?>
|
||||||
|
@State var pipStateChanged: (Bool) -> Void = {_ in }
|
||||||
|
@Binding var pipShown: Bool
|
||||||
|
|
||||||
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>) {
|
init(client: WebRTCClient, activeCall: Binding<WebRTCClient.Call?>, localRendererAspectRatio: Binding<CGFloat?>, pipShown: Binding<Bool>) {
|
||||||
self.client = client
|
self.client = client
|
||||||
self.activeCall = activeCall
|
self.activeCall = activeCall
|
||||||
self.localRendererAspectRatio = localRendererAspectRatio
|
self.localRendererAspectRatio = localRendererAspectRatio
|
||||||
|
self._pipShown = pipShown
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIView {
|
func makeUIView(context: Context) -> UIView {
|
||||||
@@ -50,12 +167,18 @@ struct CallViewLocal: UIViewRepresentable {
|
|||||||
client.addLocalRenderer(call, localRenderer)
|
client.addLocalRenderer(call, localRenderer)
|
||||||
client.startCaptureLocalVideo(call)
|
client.startCaptureLocalVideo(call)
|
||||||
addSubviewAndResize(localRenderer, into: view)
|
addSubviewAndResize(localRenderer, into: view)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
pipStateChanged = { shown in
|
||||||
|
localRenderer.isHidden = shown
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ view: UIView, context: Context) {
|
func updateUIView(_ view: UIView, context: Context) {
|
||||||
logger.debug("CallView.updateUIView local")
|
logger.debug("CallView.updateUIView local")
|
||||||
|
pipStateChanged(pipShown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct IncomingCallView: View {
|
struct IncomingCallView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var cc = CallController.shared
|
@ObservedObject var cc = CallController.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -30,8 +31,7 @@ struct IncomingCallView: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
if m.users.count > 1 {
|
if m.users.count > 1 {
|
||||||
ProfileImage(imageStr: invitation.user.image, color: .white)
|
ProfileImage(imageStr: invitation.user.image, size: 24, color: .white)
|
||||||
.frame(width: 24, height: 24)
|
|
||||||
}
|
}
|
||||||
Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green)
|
Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green)
|
||||||
Text(invitation.callTypeText)
|
Text(invitation.callTypeText)
|
||||||
@@ -44,7 +44,7 @@ struct IncomingCallView: View {
|
|||||||
cc.endCall(invitation: invitation)
|
cc.endCall(invitation: invitation)
|
||||||
}
|
}
|
||||||
|
|
||||||
callButton("Ignore", "multiply", .accentColor) {
|
callButton("Ignore", "multiply", .primary) {
|
||||||
cc.activeCallInvitation = nil
|
cc.activeCallInvitation = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ struct IncomingCallView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.modifier(ThemedBackground())
|
||||||
.onAppear { dismissAllSheets() }
|
.onAppear { dismissAllSheets() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ struct IncomingCallView: View {
|
|||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 44)
|
.frame(minWidth: 44)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
class SoundPlayer {
|
class SoundPlayer {
|
||||||
static let shared = SoundPlayer()
|
static let shared = SoundPlayer()
|
||||||
@@ -43,3 +44,63 @@ class SoundPlayer {
|
|||||||
audioPlayer = nil
|
audioPlayer = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CallSoundsPlayer {
|
||||||
|
static let shared = CallSoundsPlayer()
|
||||||
|
private var audioPlayer: AVAudioPlayer?
|
||||||
|
private var playerTask: Task = Task {}
|
||||||
|
|
||||||
|
private func start(_ soundName: String, delayMs: Double) {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
playerTask.cancel()
|
||||||
|
logger.debug("start \(soundName)")
|
||||||
|
guard let path = Bundle.main.path(forResource: soundName, ofType: "mp3", inDirectory: "sounds") else {
|
||||||
|
logger.debug("start: file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
if player.prepareToPlay() {
|
||||||
|
audioPlayer = player
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.debug("start: AVAudioPlayer error \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
playerTask = Task {
|
||||||
|
while let player = audioPlayer {
|
||||||
|
player.play()
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64((player.duration * 1_000_000_000) + delayMs * 1_000_000))
|
||||||
|
} catch {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startConnectingCallSound() {
|
||||||
|
start("connecting_call", delayMs: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startInCallSound() {
|
||||||
|
// Taken from https://github.com/TelegramOrg/Telegram-Android
|
||||||
|
// https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE
|
||||||
|
start("in_call", delayMs: 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
playerTask.cancel()
|
||||||
|
audioPlayer?.stop()
|
||||||
|
audioPlayer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vibrate(long: Bool) {
|
||||||
|
// iOS just don't want to vibrate more than once after a short period of time, and all 'styles' feel the same
|
||||||
|
if long {
|
||||||
|
AudioServicesPlayAlertSound(kSystemSoundID_Vibrate)
|
||||||
|
} else {
|
||||||
|
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class Call: ObservableObject, Equatable {
|
|||||||
@Published var speakerEnabled = false
|
@Published var speakerEnabled = false
|
||||||
@Published var videoEnabled: Bool
|
@Published var videoEnabled: Bool
|
||||||
@Published var connectionInfo: ConnectionInfo?
|
@Published var connectionInfo: ConnectionInfo?
|
||||||
|
@Published var connectedAt: Date? = nil
|
||||||
|
|
||||||
init(
|
init(
|
||||||
direction: CallDirection,
|
direction: CallDirection,
|
||||||
@@ -59,6 +60,7 @@ class Call: ObservableObject, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
|
var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } }
|
||||||
|
var supportsVideo: Bool { get { peerMedia == .video || localMedia == .video } }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CallDirection {
|
enum CallDirection {
|
||||||
@@ -335,6 +337,50 @@ extension WCallResponse: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actor WebRTCCommandProcessor {
|
||||||
|
private var client: WebRTCClient? = nil
|
||||||
|
private var commands: [WCallCommand] = []
|
||||||
|
private var running: Bool = false
|
||||||
|
|
||||||
|
func setClient(_ client: WebRTCClient?) async {
|
||||||
|
logger.debug("WebRTC: setClient, commands count \(self.commands.count)")
|
||||||
|
self.client = client
|
||||||
|
if client != nil {
|
||||||
|
await processAllCommands()
|
||||||
|
} else {
|
||||||
|
commands.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processCommand(_ c: WCallCommand) async {
|
||||||
|
// logger.debug("WebRTC: process command \(c.cmdType)")
|
||||||
|
commands.append(c)
|
||||||
|
if !running && client != nil {
|
||||||
|
await processAllCommands()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processAllCommands() async {
|
||||||
|
logger.debug("WebRTC: process all commands, commands count \(self.commands.count), client == nil \(self.client == nil)")
|
||||||
|
if let client = client {
|
||||||
|
running = true
|
||||||
|
while let c = commands.first, shouldRunCommand(client, c) {
|
||||||
|
commands.remove(at: 0)
|
||||||
|
await client.sendCallCommand(command: c)
|
||||||
|
logger.debug("WebRTC: processed cmd \(c.cmdType)")
|
||||||
|
}
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRunCommand(_ client: WebRTCClient, _ c: WCallCommand) -> Bool {
|
||||||
|
switch c {
|
||||||
|
case .capabilities, .start, .offer, .end: true
|
||||||
|
default: client.activeCall.wrappedValue != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ConnectionState: Codable, Equatable {
|
struct ConnectionState: Codable, Equatable {
|
||||||
var connectionState: String
|
var connectionState: String
|
||||||
var iceConnectionState: String
|
var iceConnectionState: String
|
||||||
@@ -358,26 +404,12 @@ struct ConnectionInfo: Codable, Equatable {
|
|||||||
return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)"
|
return "\(local?.rawValue ?? unknown) / \(remote?.rawValue ?? unknown)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var protocolText: String {
|
|
||||||
let unknown = NSLocalizedString("unknown", comment: "connection info")
|
|
||||||
let local = localCandidate?.protocol?.uppercased() ?? unknown
|
|
||||||
let localRelay = localCandidate?.relayProtocol?.uppercased() ?? unknown
|
|
||||||
let remote = remoteCandidate?.protocol?.uppercased() ?? unknown
|
|
||||||
let localText = localRelay == local || localCandidate?.relayProtocol == nil
|
|
||||||
? local
|
|
||||||
: "\(local) (\(localRelay))"
|
|
||||||
return local == remote
|
|
||||||
? localText
|
|
||||||
: "\(localText) / \(remote)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||||
struct RTCIceCandidate: Codable, Equatable {
|
struct RTCIceCandidate: Codable, Equatable {
|
||||||
var candidateType: RTCIceCandidateType?
|
var candidateType: RTCIceCandidateType?
|
||||||
var `protocol`: String?
|
var `protocol`: String?
|
||||||
var relayProtocol: String?
|
|
||||||
var sdpMid: String?
|
var sdpMid: String?
|
||||||
var sdpMLineIndex: Int?
|
var sdpMLineIndex: Int?
|
||||||
var candidate: String
|
var candidate: String
|
||||||
@@ -399,17 +431,18 @@ struct RTCIceServer: Codable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// the servers are expected in this format:
|
// the servers are expected in this format:
|
||||||
// stun:stun.simplex.im:443?transport=tcp
|
// stuns:stun.simplex.im:443?transport=tcp
|
||||||
// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp
|
// turns:private2:Hxuq2QxUjnhj96Zq2r4HjqHRj@turn.simplex.im:443?transport=tcp
|
||||||
func parseRTCIceServer(_ str: String) -> RTCIceServer? {
|
func parseRTCIceServer(_ str: String) -> RTCIceServer? {
|
||||||
var s = replaceScheme(str, "stun:")
|
var s = replaceScheme(str, "stun:")
|
||||||
|
s = replaceScheme(s, "stuns:")
|
||||||
s = replaceScheme(s, "turn:")
|
s = replaceScheme(s, "turn:")
|
||||||
s = replaceScheme(s, "turns:")
|
s = replaceScheme(s, "turns:")
|
||||||
if let u: URL = URL(string: s),
|
if let u: URL = URL(string: s),
|
||||||
let scheme = u.scheme,
|
let scheme = u.scheme,
|
||||||
let host = u.host,
|
let host = u.host,
|
||||||
let port = u.port,
|
let port = u.port,
|
||||||
u.path == "" && (scheme == "stun" || scheme == "turn" || scheme == "turns") {
|
u.path == "" && (scheme == "stun" || scheme == "stuns" || scheme == "turn" || scheme == "turns") {
|
||||||
let query = u.query == nil || u.query == "" ? "" : "?" + (u.query ?? "")
|
let query = u.query == nil || u.query == "" ? "" : "?" + (u.query ?? "")
|
||||||
return RTCIceServer(
|
return RTCIceServer(
|
||||||
urls: ["\(scheme):\(host):\(port)\(query)"],
|
urls: ["\(scheme):\(host):\(port)\(query)"],
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
}()
|
}()
|
||||||
private static let ivTagBytes: Int = 28
|
private static let ivTagBytes: Int = 28
|
||||||
private static let enableEncryption: Bool = true
|
private static let enableEncryption: Bool = true
|
||||||
|
private var chat_ctrl = getChatCtrl()
|
||||||
|
|
||||||
struct Call {
|
struct Call {
|
||||||
var connection: RTCPeerConnection
|
var connection: RTCPeerConnection
|
||||||
var iceCandidates: [RTCIceCandidate]
|
var iceCandidates: IceCandidates
|
||||||
var localMedia: CallMediaType
|
var localMedia: CallMediaType
|
||||||
var localCamera: RTCVideoCapturer?
|
var localCamera: RTCVideoCapturer?
|
||||||
var localVideoSource: RTCVideoSource?
|
var localVideoSource: RTCVideoSource?
|
||||||
@@ -33,10 +34,24 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
var frameDecryptor: RTCFrameDecryptor?
|
var frameDecryptor: RTCFrameDecryptor?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actor IceCandidates {
|
||||||
|
private var candidates: [RTCIceCandidate] = []
|
||||||
|
|
||||||
|
func getAndClear() async -> [RTCIceCandidate] {
|
||||||
|
let cs = candidates
|
||||||
|
candidates = []
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
|
func append(_ c: RTCIceCandidate) async {
|
||||||
|
candidates.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private let rtcAudioSession = RTCAudioSession.sharedInstance()
|
private let rtcAudioSession = RTCAudioSession.sharedInstance()
|
||||||
private let audioQueue = DispatchQueue(label: "audio")
|
private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio")
|
||||||
private var sendCallResponse: (WVAPIMessage) async -> Void
|
private var sendCallResponse: (WVAPIMessage) async -> Void
|
||||||
private var activeCall: Binding<Call?>
|
var activeCall: Binding<Call?>
|
||||||
private var localRendererAspectRatio: Binding<CGFloat?>
|
private var localRendererAspectRatio: Binding<CGFloat?>
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -50,17 +65,17 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
self.localRendererAspectRatio = localRendererAspectRatio
|
self.localRendererAspectRatio = localRendererAspectRatio
|
||||||
rtcAudioSession.useManualAudio = CallController.useCallKit()
|
rtcAudioSession.useManualAudio = CallController.useCallKit()
|
||||||
rtcAudioSession.isAudioEnabled = !CallController.useCallKit()
|
rtcAudioSession.isAudioEnabled = !CallController.useCallKit()
|
||||||
logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)}")
|
logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)")
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
let defaultIceServers: [WebRTC.RTCIceServer] = [
|
let defaultIceServers: [WebRTC.RTCIceServer] = [
|
||||||
WebRTC.RTCIceServer(urlStrings: ["stun:stun.simplex.im:443"]),
|
WebRTC.RTCIceServer(urlStrings: ["stuns:stun.simplex.im:443"]),
|
||||||
WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
|
//WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=udp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"),
|
||||||
WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"),
|
WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"),
|
||||||
]
|
]
|
||||||
|
|
||||||
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ remoteIceCandidates: [RTCIceCandidate], _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
||||||
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
|
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
|
||||||
connection.delegate = self
|
connection.delegate = self
|
||||||
createAudioSender(connection)
|
createAudioSender(connection)
|
||||||
@@ -87,7 +102,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
}
|
}
|
||||||
return Call(
|
return Call(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
iceCandidates: remoteIceCandidates,
|
iceCandidates: IceCandidates(),
|
||||||
localMedia: mediaType,
|
localMedia: mediaType,
|
||||||
localCamera: localCamera,
|
localCamera: localCamera,
|
||||||
localVideoSource: localVideoSource,
|
localVideoSource: localVideoSource,
|
||||||
@@ -144,26 +159,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
logger.debug("starting incoming call - create webrtc session")
|
logger.debug("starting incoming call - create webrtc session")
|
||||||
if activeCall.wrappedValue != 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.wrappedValue = call
|
activeCall.wrappedValue = call
|
||||||
call.connection.offer { answer in
|
let (offer, error) = await call.connection.offer()
|
||||||
Task {
|
if let offer = offer {
|
||||||
let gotCandidates = await self.waitWithTimeout(10_000, stepMs: 1000, until: { self.activeCall.wrappedValue?.iceCandidates.count ?? 0 > 0 })
|
resp = .offer(
|
||||||
if gotCandidates {
|
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: offer.type.toSdpType(), sdp: offer.sdp))),
|
||||||
await self.sendCallResponse(.init(
|
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates())),
|
||||||
corrId: nil,
|
capabilities: CallCapabilities(encryption: encryption)
|
||||||
resp: .offer(
|
)
|
||||||
offer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
self.waitForMoreIceCandidates()
|
||||||
iceCandidates: compressToBase64(input: encodeJSON(self.activeCall.wrappedValue?.iceCandidates ?? [])),
|
} else {
|
||||||
capabilities: CallCapabilities(encryption: encryption)
|
resp = .error(message: "offer error: \(error?.localizedDescription ?? "unknown error")")
|
||||||
),
|
|
||||||
command: command)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
self.endCall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
|
case let .offer(offer, iceCandidates, media, aesKey, iceServers, relay):
|
||||||
if activeCall.wrappedValue != nil {
|
if activeCall.wrappedValue != nil {
|
||||||
@@ -172,26 +179,21 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
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(), remoteIceCandidates, media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
let call = initializeCall(iceServers?.toWebRTCIceServers(), media, WebRTCClient.enableEncryption ? aesKey : nil, relay)
|
||||||
activeCall.wrappedValue = 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 {
|
||||||
pc.answer { answer in
|
let (answer, error) = await pc.answer()
|
||||||
|
if let answer = answer {
|
||||||
self.addIceCandidates(pc, remoteIceCandidates)
|
self.addIceCandidates(pc, remoteIceCandidates)
|
||||||
// Task {
|
resp = .answer(
|
||||||
// try? await Task.sleep(nanoseconds: 32_000 * 1000000)
|
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
||||||
Task {
|
iceCandidates: compressToBase64(input: encodeJSON(await self.getInitialIceCandidates()))
|
||||||
await self.sendCallResponse(.init(
|
)
|
||||||
corrId: nil,
|
self.waitForMoreIceCandidates()
|
||||||
resp: .answer(
|
} else {
|
||||||
answer: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: answer.type.toSdpType(), sdp: answer.sdp))),
|
resp = .error(message: "answer error: \(error?.localizedDescription ?? "unknown error")")
|
||||||
iceCandidates: compressToBase64(input: encodeJSON(call.iceCandidates))
|
|
||||||
),
|
|
||||||
command: command)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
resp = .error(message: "accept: remote description is not set")
|
resp = .error(message: "accept: remote description is not set")
|
||||||
@@ -234,6 +236,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
resp = .ok
|
resp = .ok
|
||||||
}
|
}
|
||||||
case .end:
|
case .end:
|
||||||
|
// TODO possibly, endCall should be called before returning .ok
|
||||||
await sendCallResponse(.init(corrId: nil, resp: .ok, command: command))
|
await sendCallResponse(.init(corrId: nil, resp: .ok, command: command))
|
||||||
endCall()
|
endCall()
|
||||||
}
|
}
|
||||||
@@ -242,6 +245,33 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getInitialIceCandidates() async -> [RTCIceCandidate] {
|
||||||
|
await untilIceComplete(timeoutMs: 750, stepMs: 150) {}
|
||||||
|
let candidates = await activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
|
||||||
|
logger.debug("WebRTCClient: sending initial ice candidates: \(candidates.count)")
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForMoreIceCandidates() {
|
||||||
|
Task {
|
||||||
|
await untilIceComplete(timeoutMs: 12000, stepMs: 1500) {
|
||||||
|
let candidates = await self.activeCall.wrappedValue?.iceCandidates.getAndClear() ?? []
|
||||||
|
if candidates.count > 0 {
|
||||||
|
logger.debug("WebRTCClient: sending more ice candidates: \(candidates.count)")
|
||||||
|
await self.sendIceCandidates(candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
|
||||||
|
await self.sendCallResponse(.init(
|
||||||
|
corrId: nil,
|
||||||
|
resp: .ice(iceCandidates: compressToBase64(input: encodeJSON(candidates))),
|
||||||
|
command: nil)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func enableMedia(_ media: CallMediaType, _ enable: Bool) {
|
func enableMedia(_ media: CallMediaType, _ enable: Bool) {
|
||||||
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
|
logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)")
|
||||||
media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
|
media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable)
|
||||||
@@ -279,7 +309,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
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
|
||||||
let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3
|
let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3
|
||||||
logCrypto("encrypt", chat_encrypt_media(&key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize)))
|
logCrypto("encrypt", chat_encrypt_media(chat_ctrl, &key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize)))
|
||||||
return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes)
|
return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
@@ -301,6 +331,10 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
activeCall.remoteStream?.add(renderer)
|
activeCall.remoteStream?.add(renderer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeRemoteRenderer(_ activeCall: Call, _ renderer: RTCVideoRenderer) {
|
||||||
|
activeCall.remoteStream?.remove(renderer)
|
||||||
|
}
|
||||||
|
|
||||||
func startCaptureLocalVideo(_ activeCall: Call) {
|
func startCaptureLocalVideo(_ activeCall: Call) {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
guard
|
guard
|
||||||
@@ -380,6 +414,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
guard let call = activeCall.wrappedValue else { return }
|
guard let call = activeCall.wrappedValue else { return }
|
||||||
logger.debug("WebRTCClient: ending the call")
|
logger.debug("WebRTCClient: ending the call")
|
||||||
activeCall.wrappedValue = nil
|
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
|
||||||
@@ -387,12 +422,13 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
|||||||
audioSessionToDefaults()
|
audioSessionToDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitWithTimeout(_ timeoutMs: UInt64, stepMs: UInt64, until success: () -> Bool) async -> Bool {
|
func untilIceComplete(timeoutMs: UInt64, stepMs: UInt64, action: @escaping () async -> Void) async {
|
||||||
let startedAt = DispatchTime.now()
|
var t: UInt64 = 0
|
||||||
while !success() && startedAt.uptimeNanoseconds + timeoutMs * 1000000 > DispatchTime.now().uptimeNanoseconds {
|
repeat {
|
||||||
guard let _ = try? await Task.sleep(nanoseconds: stepMs * 1000000) else { break }
|
_ = try? await Task.sleep(nanoseconds: stepMs * 1000000)
|
||||||
}
|
t += stepMs
|
||||||
return success()
|
await action()
|
||||||
|
} while t < timeoutMs && activeCall.wrappedValue?.connection.iceGatheringState != .complete
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,25 +441,33 @@ extension WebRTC.RTCPeerConnection {
|
|||||||
optionalConstraints: nil)
|
optionalConstraints: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func offer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
|
func offer() async -> (RTCSessionDescription?, Error?) {
|
||||||
offer(for: mediaConstraints()) { (sdp, error) in
|
await withCheckedContinuation { cont in
|
||||||
guard let sdp = sdp else {
|
offer(for: mediaConstraints()) { (sdp, error) in
|
||||||
return
|
self.processSDP(cont, sdp, error)
|
||||||
}
|
}
|
||||||
self.setLocalDescription(sdp, completionHandler: { (error) in
|
|
||||||
completion(sdp)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func answer(_ completion: @escaping (_ sdp: RTCSessionDescription) -> Void) {
|
func answer() async -> (RTCSessionDescription?, Error?) {
|
||||||
answer(for: mediaConstraints()) { (sdp, error) in
|
await withCheckedContinuation { cont in
|
||||||
guard let sdp = sdp else {
|
answer(for: mediaConstraints()) { (sdp, error) in
|
||||||
return
|
self.processSDP(cont, sdp, error)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processSDP(_ cont: CheckedContinuation<(RTCSessionDescription?, Error?), Never>, _ sdp: RTCSessionDescription?, _ error: Error?) {
|
||||||
|
if let sdp = sdp {
|
||||||
self.setLocalDescription(sdp, completionHandler: { (error) in
|
self.setLocalDescription(sdp, completionHandler: { (error) in
|
||||||
completion(sdp)
|
if let error = error {
|
||||||
|
cont.resume(returning: (nil, error))
|
||||||
|
} else {
|
||||||
|
cont.resume(returning: (sdp, nil))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
cont.resume(returning: (nil, error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,6 +523,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
|||||||
default: enableSpeaker = false
|
default: enableSpeaker = false
|
||||||
}
|
}
|
||||||
setSpeakerEnabledAndConfigureSession(enableSpeaker)
|
setSpeakerEnabledAndConfigureSession(enableSpeaker)
|
||||||
|
case .connected: sendConnectedEvent(connection)
|
||||||
case .disconnected, .failed: endCall()
|
case .disconnected, .failed: endCall()
|
||||||
default: do {}
|
default: do {}
|
||||||
}
|
}
|
||||||
@@ -491,7 +536,9 @@ 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)")
|
||||||
activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil, nil))
|
Task {
|
||||||
|
await self.activeCall.wrappedValue?.iceCandidates.append(candidate.toCandidate(nil, nil))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) {
|
func peerConnection(_ connection: RTCPeerConnection, didRemove candidates: [WebRTC.RTCIceCandidate]) {
|
||||||
@@ -506,10 +553,9 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
|||||||
lastReceivedMs lastDataReceivedMs: Int32,
|
lastReceivedMs lastDataReceivedMs: Int32,
|
||||||
changeReason reason: String) {
|
changeReason reason: String) {
|
||||||
// logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)")
|
// logger.debug("Connection changed candidate \(reason) \(remote.debugDescription) \(remote.description)")
|
||||||
sendConnectedEvent(connection, local: local, remote: remote)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection, local: WebRTC.RTCIceCandidate, remote: WebRTC.RTCIceCandidate) {
|
func sendConnectedEvent(_ connection: WebRTC.RTCPeerConnection) {
|
||||||
connection.statistics { (stats: RTCStatisticsReport) in
|
connection.statistics { (stats: RTCStatisticsReport) in
|
||||||
stats.statistics.values.forEach { stat in
|
stats.statistics.values.forEach { stat in
|
||||||
// logger.debug("Stat \(stat.debugDescription)")
|
// logger.debug("Stat \(stat.debugDescription)")
|
||||||
@@ -517,24 +563,25 @@ extension WebRTCClient: RTCPeerConnectionDelegate {
|
|||||||
let localId = stat.values["localCandidateId"] as? String,
|
let localId = stat.values["localCandidateId"] as? String,
|
||||||
let remoteId = stat.values["remoteCandidateId"] as? String,
|
let remoteId = stat.values["remoteCandidateId"] as? String,
|
||||||
let localStats = stats.statistics[localId],
|
let localStats = stats.statistics[localId],
|
||||||
let remoteStats = stats.statistics[remoteId],
|
let remoteStats = stats.statistics[remoteId]
|
||||||
local.sdp.contains("\((localStats.values["ip"] as? String ?? "--")) \((localStats.values["port"] as? String ?? "--"))") &&
|
|
||||||
remote.sdp.contains("\((remoteStats.values["ip"] as? String ?? "--")) \((remoteStats.values["port"] as? String ?? "--"))")
|
|
||||||
{
|
{
|
||||||
Task {
|
Task {
|
||||||
await self.sendCallResponse(.init(
|
await self.sendCallResponse(.init(
|
||||||
corrId: nil,
|
corrId: nil,
|
||||||
resp: .connected(connectionInfo: ConnectionInfo(
|
resp: .connected(connectionInfo: ConnectionInfo(
|
||||||
localCandidate: local.toCandidate(
|
localCandidate: RTCIceCandidate(
|
||||||
RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
|
candidateType: RTCIceCandidateType.init(rawValue: localStats.values["candidateType"] as! String),
|
||||||
localStats.values["protocol"] as? String,
|
protocol: localStats.values["protocol"] as? String,
|
||||||
localStats.values["relayProtocol"] as? String
|
sdpMid: nil,
|
||||||
|
sdpMLineIndex: nil,
|
||||||
|
candidate: ""
|
||||||
),
|
),
|
||||||
remoteCandidate: remote.toCandidate(
|
remoteCandidate: RTCIceCandidate(
|
||||||
RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
|
candidateType: RTCIceCandidateType.init(rawValue: remoteStats.values["candidateType"] as! String),
|
||||||
remoteStats.values["protocol"] as? String,
|
protocol: remoteStats.values["protocol"] as? String,
|
||||||
remoteStats.values["relayProtocol"] as? String
|
sdpMid: nil,
|
||||||
))),
|
sdpMLineIndex: nil,
|
||||||
|
candidate: ""))),
|
||||||
command: nil)
|
command: nil)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -558,9 +605,23 @@ extension WebRTCClient {
|
|||||||
self.rtcAudioSession.unlockForConfiguration()
|
self.rtcAudioSession.unlockForConfiguration()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue)
|
let hasExternalAudioDevice = self.rtcAudioSession.session.hasExternalAudioDevice()
|
||||||
try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
|
if enabled {
|
||||||
try self.rtcAudioSession.overrideOutputAudioPort(enabled ? .speaker : .none)
|
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||||
|
try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue)
|
||||||
|
if hasExternalAudioDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() {
|
||||||
|
try self.rtcAudioSession.setPreferredInput(preferred)
|
||||||
|
} else {
|
||||||
|
try self.rtcAudioSession.overrideOutputAudioPort(.speaker)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP])
|
||||||
|
try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue)
|
||||||
|
try self.rtcAudioSession.overrideOutputAudioPort(.none)
|
||||||
|
}
|
||||||
|
if hasExternalAudioDevice {
|
||||||
|
logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker")
|
||||||
|
}
|
||||||
try self.rtcAudioSession.setActive(true)
|
try self.rtcAudioSession.setActive(true)
|
||||||
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success")
|
logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success")
|
||||||
} catch let error {
|
} catch let error {
|
||||||
@@ -611,6 +672,17 @@ extension WebRTCClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension AVAudioSession {
|
||||||
|
func hasExternalAudioDevice() -> Bool {
|
||||||
|
availableInputs?.allSatisfy({ $0.portType == .builtInMic }) != true
|
||||||
|
}
|
||||||
|
|
||||||
|
func preferredInputDevice() -> AVAudioSessionPortDescription? {
|
||||||
|
// logger.debug("Preferred input device: \(String(describing: self.availableInputs?.filter({ $0.portType != .builtInMic })))")
|
||||||
|
return availableInputs?.filter({ $0.portType != .builtInMic }).last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CustomRTCSessionDescription: Codable {
|
struct CustomRTCSessionDescription: Codable {
|
||||||
public var type: RTCSdpType?
|
public var type: RTCSdpType?
|
||||||
public var sdp: String?
|
public var sdp: String?
|
||||||
@@ -634,11 +706,10 @@ extension RTCIceCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension WebRTC.RTCIceCandidate {
|
extension WebRTC.RTCIceCandidate {
|
||||||
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?, _ relayProtocol: String?) -> RTCIceCandidate {
|
func toCandidate(_ candidateType: RTCIceCandidateType?, _ protocol: String?) -> RTCIceCandidate {
|
||||||
RTCIceCandidate(
|
RTCIceCandidate(
|
||||||
candidateType: candidateType,
|
candidateType: candidateType,
|
||||||
protocol: `protocol`,
|
protocol: `protocol`,
|
||||||
relayProtocol: relayProtocol,
|
|
||||||
sdpMid: sdpMid,
|
sdpMid: sdpMid,
|
||||||
sdpMLineIndex: Int(sdpMLineIndex),
|
sdpMLineIndex: Int(sdpMLineIndex),
|
||||||
candidate: sdp
|
candidate: sdp
|
||||||
|
|||||||
@@ -9,10 +9,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
|
|
||||||
let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2)
|
|
||||||
struct ChatInfoToolbar: View {
|
struct ChatInfoToolbar: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var imageSize: CGFloat = 32
|
var imageSize: CGFloat = 32
|
||||||
|
|
||||||
@@ -25,11 +24,9 @@ struct ChatInfoToolbar: View {
|
|||||||
}
|
}
|
||||||
ChatInfoImage(
|
ChatInfoImage(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
color: colorScheme == .dark
|
size: imageSize,
|
||||||
? chatImageColorDark
|
color: Color(uiColor: .tertiaryLabel)
|
||||||
: chatImageColorLight
|
|
||||||
)
|
)
|
||||||
.frame(width: imageSize, height: imageSize)
|
|
||||||
.padding(.trailing, 4)
|
.padding(.trailing, 4)
|
||||||
VStack {
|
VStack {
|
||||||
let t = Text(cInfo.displayName).font(.headline)
|
let t = Text(cInfo.displayName).font(.headline)
|
||||||
@@ -41,14 +38,14 @@ struct ChatInfoToolbar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.frame(width: 220)
|
.frame(width: 220)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var contactVerifiedShield: Text {
|
private var contactVerifiedShield: Text {
|
||||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.baselineOffset(1)
|
.baselineOffset(1)
|
||||||
.kerning(-2)
|
.kerning(-2)
|
||||||
}
|
}
|
||||||
@@ -57,5 +54,6 @@ struct ChatInfoToolbar: View {
|
|||||||
struct ChatInfoToolbar_Previews: PreviewProvider {
|
struct ChatInfoToolbar_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
||||||
|
.environmentObject(CurrentColors.toAppTheme())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,20 +36,20 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String]) -> some View {
|
@ViewBuilder func smpServers(_ title: LocalizedStringKey, _ servers: [String], _ secondaryColor: Color) -> some View {
|
||||||
if servers.count > 0 {
|
if servers.count > 0 {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title).frame(width: 120, alignment: .leading)
|
Text(title).frame(width: 120, alignment: .leading)
|
||||||
Button(serverHost(servers[0])) {
|
Button(serverHost(servers[0])) {
|
||||||
UIPasteboard.general.string = servers.joined(separator: ";")
|
UIPasteboard.general.string = servers.joined(separator: ";")
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func serverHost(_ s: String) -> String {
|
func serverHost(_ s: String) -> String {
|
||||||
if let i = s.range(of: "@")?.lowerBound {
|
if let i = s.range(of: "@")?.lowerBound {
|
||||||
return String(s[i...].dropFirst())
|
return String(s[i...].dropFirst())
|
||||||
} else {
|
} else {
|
||||||
@@ -90,6 +90,7 @@ enum SendReceipts: Identifiable, Hashable {
|
|||||||
|
|
||||||
struct ChatInfoView: View {
|
struct ChatInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@State var contact: Contact
|
@State var contact: Contact
|
||||||
@@ -110,6 +111,7 @@ struct ChatInfoView: View {
|
|||||||
case switchAddressAlert
|
case switchAddressAlert
|
||||||
case abortSwitchAddressAlert
|
case abortSwitchAddressAlert
|
||||||
case syncConnectionForceAlert
|
case syncConnectionForceAlert
|
||||||
|
case queueInfo(info: String)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
@@ -119,6 +121,7 @@ struct ChatInfoView: View {
|
|||||||
case .switchAddressAlert: return "switchAddressAlert"
|
case .switchAddressAlert: return "switchAddressAlert"
|
||||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||||
|
case let .queueInfo(info): return "queueInfo \(info)"
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +144,7 @@ struct ChatInfoView: View {
|
|||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
if let customUserProfile = customUserProfile {
|
if let customUserProfile = customUserProfile {
|
||||||
Section("Incognito") {
|
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Your random profile")
|
Text("Your random profile")
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -159,12 +162,23 @@ struct ChatInfoView: View {
|
|||||||
connStats.ratchetSyncAllowed {
|
connStats.ratchetSyncAllowed {
|
||||||
synchronizeConnectionButton()
|
synchronizeConnectionButton()
|
||||||
}
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ChatWallpaperEditorSheet(chat: chat)
|
||||||
|
} label: {
|
||||||
|
Label("Chat theme", systemImage: "photo")
|
||||||
|
}
|
||||||
// } else if developerTools {
|
// } else if developerTools {
|
||||||
// synchronizeConnectionButtonForce()
|
// synchronizeConnectionButtonForce()
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
.disabled(!contact.ready || !contact.active)
|
.disabled(!contact.ready || !contact.active)
|
||||||
|
|
||||||
|
if let conn = contact.activeConn {
|
||||||
|
Section {
|
||||||
|
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let contactLink = contact.contactLink {
|
if let contactLink = contact.contactLink {
|
||||||
Section {
|
Section {
|
||||||
SimpleXLinkQRCode(uri: contactLink)
|
SimpleXLinkQRCode(uri: contactLink)
|
||||||
@@ -175,13 +189,15 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Address")
|
Text("Address")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if contact.ready && contact.active {
|
if contact.ready && contact.active {
|
||||||
Section("Servers") {
|
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||||
networkStatusRow()
|
networkStatusRow()
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
alert = .networkStatusAlert
|
alert = .networkStatusAlert
|
||||||
@@ -203,8 +219,8 @@ struct ChatInfoView: View {
|
|||||||
|| connStats.ratchetSyncSendProhibited
|
|| connStats.ratchetSyncSendProhibited
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,12 +231,25 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if developerTools {
|
if developerTools {
|
||||||
Section(header: Text("For console")) {
|
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||||
|
Button ("Debug delivery") {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
|
||||||
|
await MainActor.run { alert = .queueInfo(info: info) }
|
||||||
|
} catch let e {
|
||||||
|
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||||
|
let a = getErrorAlert(e, "Error")
|
||||||
|
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
@@ -237,6 +266,7 @@ struct ChatInfoView: View {
|
|||||||
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
||||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
||||||
|
case let .queueInfo(info): return queueInfoAlert(info)
|
||||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,14 +295,13 @@ struct ChatInfoView: View {
|
|||||||
private func contactInfoHeader() -> some View {
|
private func contactInfoHeader() -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
|
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||||
.frame(width: 192, height: 192)
|
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding()
|
.padding()
|
||||||
if contact.verified {
|
if contact.verified {
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "checkmark.shield"))
|
Text(Image(systemName: "checkmark.shield"))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ Text(contact.profile.displayName)
|
+ Text(contact.profile.displayName)
|
||||||
@@ -312,7 +341,7 @@ struct ChatInfoView: View {
|
|||||||
setContactAlias()
|
setContactAlias()
|
||||||
}
|
}
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setContactAlias() {
|
private func setContactAlias() {
|
||||||
@@ -350,6 +379,7 @@ struct ChatInfoView: View {
|
|||||||
)
|
)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationTitle("Security code")
|
.navigationTitle("Security code")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
contact.verified ? "View security code" : "Verify security code",
|
contact.verified ? "View security code" : "Verify security code",
|
||||||
@@ -366,6 +396,7 @@ struct ChatInfoView: View {
|
|||||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Contact preferences")
|
.navigationBarTitle("Contact preferences")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Contact preferences", systemImage: "switch.2")
|
Label("Contact preferences", systemImage: "switch.2")
|
||||||
@@ -414,11 +445,11 @@ struct ChatInfoView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text("Network status")
|
Text("Network status")
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(chatModel.contactNetworkStatus(contact).statusString)
|
Text(chatModel.contactNetworkStatus(contact).statusString)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
serverImage()
|
serverImage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -426,7 +457,7 @@ struct ChatInfoView: View {
|
|||||||
private func serverImage() -> some View {
|
private func serverImage() -> some View {
|
||||||
let status = chatModel.contactNetworkStatus(contact)
|
let status = chatModel.contactNetworkStatus(contact)
|
||||||
return Image(systemName: status.imageName)
|
return Image(systemName: status.imageName)
|
||||||
.foregroundColor(status == .connected ? .green : .secondary)
|
.foregroundColor(status == .connected ? .green : theme.colors.secondary)
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +576,148 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ChatWallpaperEditorSheet: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@State private var globalThemeUsed: Bool = false
|
||||||
|
@State var chat: Chat
|
||||||
|
@State private var themes: ThemeModeOverrides
|
||||||
|
|
||||||
|
init(chat: Chat) {
|
||||||
|
self.chat = chat
|
||||||
|
self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else {
|
||||||
|
ThemeModeOverrides()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let preferred = themes.preferredMode(!theme.colors.isLight)
|
||||||
|
let initialTheme = preferred ?? ThemeManager.defaultActiveTheme(ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||||
|
ChatWallpaperEditor(
|
||||||
|
initialTheme: initialTheme,
|
||||||
|
themeModeOverride: initialTheme,
|
||||||
|
applyToMode: themes.light == themes.dark ? nil : initialTheme.mode,
|
||||||
|
globalThemeUsed: $globalThemeUsed,
|
||||||
|
save: { applyToMode, newTheme in
|
||||||
|
await save(applyToMode, newTheme, $chat)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.navigationTitle("Chat theme")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
globalThemeUsed = preferred == nil
|
||||||
|
}
|
||||||
|
.onChange(of: theme.base.mode) { _ in
|
||||||
|
globalThemeUsed = themesFromChat(chat).preferredMode(!theme.colors.isLight) == nil
|
||||||
|
}
|
||||||
|
.onChange(of: ChatModel.shared.chatId) { _ in
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides {
|
||||||
|
if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes {
|
||||||
|
uiThemes
|
||||||
|
} else {
|
||||||
|
ThemeModeOverrides()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var updateBackendTask: Task = Task {}
|
||||||
|
private func save(
|
||||||
|
_ applyToMode: DefaultThemeMode?,
|
||||||
|
_ newTheme: ThemeModeOverride?,
|
||||||
|
_ chat: Binding<Chat>
|
||||||
|
) async {
|
||||||
|
let unchangedThemes: ThemeModeOverrides = themesFromChat(chat.wrappedValue)
|
||||||
|
var wallpaperFiles = Set([unchangedThemes.light?.wallpaper?.imageFile, unchangedThemes.dark?.wallpaper?.imageFile])
|
||||||
|
var changedThemes: ThemeModeOverrides? = unchangedThemes
|
||||||
|
let light: ThemeModeOverride? = if let newTheme {
|
||||||
|
ThemeModeOverride(mode: DefaultThemeMode.light, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
let dark: ThemeModeOverride? = if let newTheme {
|
||||||
|
ThemeModeOverride(mode: DefaultThemeMode.dark, colors: newTheme.colors, wallpaper: newTheme.wallpaper?.withFilledWallpaperPath())
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let applyToMode {
|
||||||
|
switch applyToMode {
|
||||||
|
case DefaultThemeMode.light:
|
||||||
|
changedThemes?.light = light
|
||||||
|
case DefaultThemeMode.dark:
|
||||||
|
changedThemes?.dark = dark
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
changedThemes?.light = light
|
||||||
|
changedThemes?.dark = dark
|
||||||
|
}
|
||||||
|
if changedThemes?.light != nil || changedThemes?.dark != nil {
|
||||||
|
let light = changedThemes?.light
|
||||||
|
let dark = changedThemes?.dark
|
||||||
|
let currentMode = CurrentColors.base.mode
|
||||||
|
// same image file for both modes, copy image to make them as different files
|
||||||
|
if var light, var dark, let lightWallpaper = light.wallpaper, let darkWallpaper = dark.wallpaper, let lightImageFile = lightWallpaper.imageFile, let darkImageFile = darkWallpaper.imageFile, lightWallpaper.imageFile == darkWallpaper.imageFile {
|
||||||
|
let imageFile = if currentMode == DefaultThemeMode.light {
|
||||||
|
darkImageFile
|
||||||
|
} else {
|
||||||
|
lightImageFile
|
||||||
|
}
|
||||||
|
let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile))
|
||||||
|
if currentMode == DefaultThemeMode.light {
|
||||||
|
dark.wallpaper?.imageFile = filePath
|
||||||
|
changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark)
|
||||||
|
} else {
|
||||||
|
light.wallpaper?.imageFile = filePath
|
||||||
|
changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
changedThemes = nil
|
||||||
|
}
|
||||||
|
wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile)
|
||||||
|
wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile)
|
||||||
|
wallpaperFiles.forEach(removeWallpaperFile)
|
||||||
|
|
||||||
|
let changedThemesConstant = changedThemes
|
||||||
|
ChatWallpaperEditorSheet.updateBackendTask.cancel()
|
||||||
|
ChatWallpaperEditorSheet.updateBackendTask = Task {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 300_000000)
|
||||||
|
if await apiSetChatUIThemes(chatId: chat.id, themes: changedThemesConstant) {
|
||||||
|
if case var ChatInfo.direct(contact) = chat.wrappedValue.chatInfo {
|
||||||
|
contact.uiThemes = changedThemesConstant
|
||||||
|
await MainActor.run {
|
||||||
|
ChatModel.shared.updateChatInfo(ChatInfo.direct(contact: contact))
|
||||||
|
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact))
|
||||||
|
themes = themesFromChat(chat.wrappedValue)
|
||||||
|
}
|
||||||
|
} else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo {
|
||||||
|
groupInfo.uiThemes = changedThemesConstant
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo))
|
||||||
|
chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo))
|
||||||
|
themes = themesFromChat(chat.wrappedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// canceled task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Change receiving address?"),
|
title: Text("Change receiving address?"),
|
||||||
@@ -572,6 +745,22 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queueInfoText(_ info: (RcvMsgInfo?, QueueInfo)) -> String {
|
||||||
|
let (rcvMsgInfo, qInfo) = info
|
||||||
|
var msgInfo: String
|
||||||
|
if let rcvMsgInfo { msgInfo = encodeJSON(rcvMsgInfo) } else { msgInfo = "none" }
|
||||||
|
return String.localizedStringWithFormat(NSLocalizedString("server queue info: %@\n\nlast received msg: %@", comment: "queue info"), encodeJSON(qInfo), msgInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queueInfoAlert(_ info: String) -> Alert {
|
||||||
|
Alert(
|
||||||
|
title: Text("Message queue info"),
|
||||||
|
message: Text(info),
|
||||||
|
primaryButton: .default(Text("Ok")),
|
||||||
|
secondaryButton: .default(Text("Copy")) { UIPasteboard.general.string = info }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatInfoView_Previews: PreviewProvider {
|
struct ChatInfoView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ChatInfoView(
|
ChatInfoView(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CICallItemView: View {
|
struct CICallItemView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var status: CICallStatus
|
var status: CICallStatus
|
||||||
@@ -22,7 +23,7 @@ struct CICallItemView: View {
|
|||||||
switch status {
|
switch status {
|
||||||
case .pending:
|
case .pending:
|
||||||
if sent {
|
if sent {
|
||||||
Image(systemName: "phone.arrow.up.right").foregroundColor(.secondary)
|
Image(systemName: "phone.arrow.up.right").foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
acceptCallButton()
|
acceptCallButton()
|
||||||
}
|
}
|
||||||
@@ -35,9 +36,7 @@ struct CICallItemView: View {
|
|||||||
case .error: missedCallIcon(sent).foregroundColor(.orange)
|
case .error: missedCallIcon(sent).foregroundColor(.orange)
|
||||||
}
|
}
|
||||||
|
|
||||||
chatItem.timestampText
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
@@ -54,7 +53,7 @@ struct CICallItemView: View {
|
|||||||
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "phone.down")
|
Image(systemName: "phone.down")
|
||||||
Text(durationText(duration)).foregroundColor(.secondary)
|
Text(durationText(duration)).foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +71,7 @@ struct CICallItemView: View {
|
|||||||
Label("Answer call", systemImage: "phone.arrow.down.left")
|
Label("Answer call", systemImage: "phone.arrow.down.left")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIChatFeatureView: View {
|
struct CIChatFeatureView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
var feature: Feature
|
var feature: Feature
|
||||||
@@ -18,7 +20,7 @@ struct CIChatFeatureView: View {
|
|||||||
var iconColor: Color
|
var iconColor: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if !revealed, let fs = mergedFeautures() {
|
if !revealed, let fs = mergedFeatures() {
|
||||||
HStack {
|
HStack {
|
||||||
ForEach(fs, content: featureIconView)
|
ForEach(fs, content: featureIconView)
|
||||||
}
|
}
|
||||||
@@ -47,7 +49,7 @@ struct CIChatFeatureView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mergedFeautures() -> [FeatureInfo]? {
|
private func mergedFeatures() -> [FeatureInfo]? {
|
||||||
var fs: [FeatureInfo] = []
|
var fs: [FeatureInfo] = []
|
||||||
var icons: Set<String> = []
|
var icons: Set<String> = []
|
||||||
if var i = m.getChatItemIndex(chatItem) {
|
if var i = m.getChatItemIndex(chatItem) {
|
||||||
@@ -65,10 +67,10 @@ struct CIChatFeatureView: View {
|
|||||||
|
|
||||||
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
||||||
switch ci.content {
|
switch ci.content {
|
||||||
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
|
||||||
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor(theme.colors.secondary), param)
|
||||||
case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
|
case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
|
||||||
case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
|
case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary), param)
|
||||||
default: nil
|
default: nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +82,7 @@ struct CIChatFeatureView: View {
|
|||||||
if let param = f.param {
|
if let param = f.param {
|
||||||
HStack {
|
HStack {
|
||||||
i
|
i
|
||||||
chatEventText(Text(param)).lineLimit(1)
|
chatEventText(Text(param), theme.colors.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
i
|
i
|
||||||
@@ -92,7 +94,7 @@ struct CIChatFeatureView: View {
|
|||||||
Image(systemName: icon ?? feature.iconFilled)
|
Image(systemName: icon ?? feature.iconFilled)
|
||||||
.foregroundColor(iconColor)
|
.foregroundColor(iconColor)
|
||||||
.scaleEffect(feature.iconScale)
|
.scaleEffect(feature.iconScale)
|
||||||
chatEventText(chatItem)
|
chatEventText(chatItem, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -103,6 +105,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(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
|
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ struct CIEventView: View {
|
|||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
|
.lineLimit(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIFeaturePreferenceView: View {
|
struct CIFeaturePreferenceView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var feature: ChatFeature
|
var feature: ChatFeature
|
||||||
var allowed: FeatureAllowed
|
var allowed: FeatureAllowed
|
||||||
@@ -19,7 +20,7 @@ struct CIFeaturePreferenceView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
Image(systemName: feature.icon)
|
Image(systemName: feature.icon)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.scaleEffect(feature.iconScale)
|
.scaleEffect(feature.iconScale)
|
||||||
if let ct = chat.chatInfo.contact,
|
if let ct = chat.chatInfo.contact,
|
||||||
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
|
||||||
@@ -40,17 +41,17 @@ struct CIFeaturePreferenceView: View {
|
|||||||
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
private func featurePreferenceView(acceptText: LocalizedStringKey? = nil) -> some View {
|
||||||
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
var r = Text(CIContent.preferenceText(feature, allowed, param) + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
if let acceptText {
|
if let acceptText {
|
||||||
r = r
|
r = r
|
||||||
+ Text(acceptText)
|
+ Text(acceptText)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
}
|
}
|
||||||
r = r + chatItem.timestampText
|
r = r + chatItem.timestampText
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
return r.font(.caption)
|
return r.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIFileView: View {
|
struct CIFileView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let file: CIFile?
|
let file: CIFile?
|
||||||
let edited: Bool
|
let edited: Bool
|
||||||
|
|
||||||
@@ -30,12 +30,12 @@ struct CIFileView: View {
|
|||||||
Text(file.fileName)
|
Text(file.fileName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
Text(prettyFileSize + metaReserve)
|
Text(prettyFileSize + metaReserve)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(metaReserve)
|
Text(metaReserve)
|
||||||
@@ -52,41 +52,36 @@ struct CIFileView: View {
|
|||||||
private var itemInteractive: Bool {
|
private var itemInteractive: Bool {
|
||||||
if let file = file {
|
if let file = file {
|
||||||
switch (file.fileStatus) {
|
switch (file.fileStatus) {
|
||||||
case .sndStored: return false
|
case .sndStored: return file.fileProtocol == .local
|
||||||
case .sndTransfer: return false
|
case .sndTransfer: return false
|
||||||
case .sndComplete: return false
|
case .sndComplete: return true
|
||||||
case .sndCancelled: return false
|
case .sndCancelled: return false
|
||||||
case .sndError: return false
|
case .sndError: return true
|
||||||
|
case .sndWarning: return true
|
||||||
case .rcvInvitation: return true
|
case .rcvInvitation: return true
|
||||||
case .rcvAccepted: return true
|
case .rcvAccepted: return true
|
||||||
case .rcvTransfer: return false
|
case .rcvTransfer: return false
|
||||||
|
case .rcvAborted: return true
|
||||||
case .rcvComplete: return true
|
case .rcvComplete: return true
|
||||||
case .rcvCancelled: return false
|
case .rcvCancelled: return false
|
||||||
case .rcvError: return false
|
case .rcvError: return true
|
||||||
|
case .rcvWarning: return true
|
||||||
case .invalid: return false
|
case .invalid: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fileSizeValid() -> Bool {
|
|
||||||
if let file = file {
|
|
||||||
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fileAction() {
|
private func fileAction() {
|
||||||
logger.debug("CIFileView fileAction")
|
logger.debug("CIFileView fileAction")
|
||||||
if let file = file {
|
if let file = file {
|
||||||
switch (file.fileStatus) {
|
switch (file.fileStatus) {
|
||||||
case .rcvInvitation:
|
case .rcvInvitation, .rcvAborted:
|
||||||
if fileSizeValid() {
|
if fileSizeValid(file) {
|
||||||
Task {
|
Task {
|
||||||
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
|
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task")
|
||||||
if let user = m.currentUser {
|
if let user = m.currentUser {
|
||||||
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
await receiveFile(user: user, fileId: file.fileId)
|
||||||
await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -108,12 +103,47 @@ struct CIFileView: View {
|
|||||||
title: "Waiting for file",
|
title: "Waiting for file",
|
||||||
message: "File will be received when your contact is online, please wait or check later!"
|
message: "File will be received when your contact is online, please wait or check later!"
|
||||||
)
|
)
|
||||||
|
case .local: ()
|
||||||
}
|
}
|
||||||
case .rcvComplete:
|
case .rcvComplete:
|
||||||
logger.debug("CIFileView fileAction - in .rcvComplete")
|
logger.debug("CIFileView fileAction - in .rcvComplete")
|
||||||
if let fileSource = getLoadedFileSource(file) {
|
if let fileSource = getLoadedFileSource(file) {
|
||||||
saveCryptoFile(fileSource)
|
saveCryptoFile(fileSource)
|
||||||
}
|
}
|
||||||
|
case let .rcvError(rcvFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .rcvError")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .rcvWarning")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case .sndStored:
|
||||||
|
logger.debug("CIFileView fileAction - in .sndStored")
|
||||||
|
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
|
||||||
|
saveCryptoFile(fileSource)
|
||||||
|
}
|
||||||
|
case .sndComplete:
|
||||||
|
logger.debug("CIFileView fileAction - in .sndComplete")
|
||||||
|
if let fileSource = getLoadedFileSource(file) {
|
||||||
|
saveCryptoFile(fileSource)
|
||||||
|
}
|
||||||
|
case let .sndError(sndFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .sndError")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
logger.debug("CIFileView fileAction - in .sndWarning")
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,18 +156,21 @@ struct CIFileView: View {
|
|||||||
switch file.fileProtocol {
|
switch file.fileProtocol {
|
||||||
case .xftp: progressView()
|
case .xftp: progressView()
|
||||||
case .smp: fileIcon("doc.fill")
|
case .smp: fileIcon("doc.fill")
|
||||||
|
case .local: fileIcon("doc.fill")
|
||||||
}
|
}
|
||||||
case let .sndTransfer(sndProgress, sndTotal):
|
case let .sndTransfer(sndProgress, sndTotal):
|
||||||
switch file.fileProtocol {
|
switch file.fileProtocol {
|
||||||
case .xftp: progressCircle(sndProgress, sndTotal)
|
case .xftp: progressCircle(sndProgress, sndTotal)
|
||||||
case .smp: progressView()
|
case .smp: progressView()
|
||||||
|
case .local: EmptyView()
|
||||||
}
|
}
|
||||||
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
||||||
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
|
case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
|
||||||
case .rcvInvitation:
|
case .rcvInvitation:
|
||||||
if fileSizeValid() {
|
if fileSizeValid(file) {
|
||||||
fileIcon("arrow.down.doc.fill", color: .accentColor)
|
fileIcon("arrow.down.doc.fill", color: theme.colors.primary)
|
||||||
} else {
|
} else {
|
||||||
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
||||||
}
|
}
|
||||||
@@ -148,9 +181,12 @@ struct CIFileView: View {
|
|||||||
} else {
|
} else {
|
||||||
progressView()
|
progressView()
|
||||||
}
|
}
|
||||||
|
case .rcvAborted:
|
||||||
|
fileIcon("doc.fill", color: theme.colors.primary, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
|
||||||
case .rcvComplete: fileIcon("doc.fill")
|
case .rcvComplete: fileIcon("doc.fill")
|
||||||
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||||
|
case .rcvWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
|
||||||
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -194,6 +230,13 @@ struct CIFileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fileSizeValid(_ file: CIFile?) -> Bool {
|
||||||
|
if let file = file {
|
||||||
|
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func saveCryptoFile(_ fileSource: CryptoFile) {
|
func saveCryptoFile(_ fileSource: CryptoFile) {
|
||||||
if let cfArgs = fileSource.cryptoArgs {
|
if let cfArgs = fileSource.cryptoArgs {
|
||||||
let url = getAppFilePath(fileSource.filePath)
|
let url = getAppFilePath(fileSource.filePath)
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIGroupInvitationView: View {
|
struct CIGroupInvitationView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var groupInvitation: CIGroupInvitation
|
var groupInvitation: CIGroupInvitation
|
||||||
var memberRole: GroupMemberRole
|
var memberRole: GroupMemberRole
|
||||||
@@ -20,6 +21,8 @@ struct CIGroupInvitationView: View {
|
|||||||
@State private var inProgress = false
|
@State private var inProgress = false
|
||||||
@State private var progressByTimeout = false
|
@State private var progressByTimeout = false
|
||||||
|
|
||||||
|
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
|
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
|
||||||
let v = ZStack(alignment: .bottomTrailing) {
|
let v = ZStack(alignment: .bottomTrailing) {
|
||||||
@@ -37,16 +40,22 @@ struct CIGroupInvitationView: View {
|
|||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
groupInvitationText()
|
groupInvitationText()
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
(
|
||||||
.foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
|
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
||||||
.font(.callout)
|
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
|
||||||
.padding(.trailing, 60)
|
.font(.callout)
|
||||||
.overlay(DetermineWidth())
|
+ Text(" ")
|
||||||
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
|
||||||
|
)
|
||||||
|
.overlay(DetermineWidth())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
groupInvitationText()
|
(
|
||||||
.padding(.trailing, 60)
|
groupInvitationText()
|
||||||
.overlay(DetermineWidth())
|
+ Text(" ")
|
||||||
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy)
|
||||||
|
)
|
||||||
|
.overlay(DetermineWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 2)
|
.padding(.bottom, 2)
|
||||||
@@ -56,13 +65,11 @@ struct CIGroupInvitationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chatItem.timestampText
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary, showStatus: false, showEdited: false)
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
.cornerRadius(18)
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||||
@@ -92,7 +99,7 @@ struct CIGroupInvitationView: View {
|
|||||||
private func groupInfoView(_ action: Bool) -> some View {
|
private func groupInfoView(_ action: Bool) -> some View {
|
||||||
var color: Color
|
var color: Color
|
||||||
if action && !inProgress {
|
if action && !inProgress {
|
||||||
color = chatIncognito ? .indigo : .accentColor
|
color = chatIncognito ? .indigo : theme.colors.primary
|
||||||
} else {
|
} else {
|
||||||
color = Color(uiColor: .tertiaryLabel)
|
color = Color(uiColor: .tertiaryLabel)
|
||||||
}
|
}
|
||||||
@@ -100,9 +107,9 @@ struct CIGroupInvitationView: View {
|
|||||||
ProfileImage(
|
ProfileImage(
|
||||||
imageStr: groupInvitation.groupProfile.image,
|
imageStr: groupInvitation.groupProfile.image,
|
||||||
iconName: "person.2.circle.fill",
|
iconName: "person.2.circle.fill",
|
||||||
|
size: 44,
|
||||||
color: color
|
color: color
|
||||||
)
|
)
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
.padding(.trailing, 4)
|
.padding(.trailing, 4)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
let p = groupInvitation.groupProfile
|
let p = groupInvitation.groupProfile
|
||||||
@@ -115,7 +122,7 @@ struct CIGroupInvitationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func groupInvitationText() -> some View {
|
private func groupInvitationText() -> Text {
|
||||||
Text(groupInvitationStr())
|
Text(groupInvitationStr())
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
}
|
}
|
||||||
@@ -137,8 +144,8 @@ struct CIGroupInvitationView: View {
|
|||||||
struct CIGroupInvitationView_Previews: PreviewProvider {
|
struct CIGroupInvitationView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin)
|
CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin)
|
||||||
CIGroupInvitationView(chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin)
|
CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,10 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIImageView: View {
|
struct CIImageView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
let chatItem: ChatItem
|
let chatItem: ChatItem
|
||||||
let image: String
|
var preview: UIImage?
|
||||||
let maxWidth: CGFloat
|
let maxWidth: CGFloat
|
||||||
@Binding var imgWidth: CGFloat?
|
var imgWidth: CGFloat?
|
||||||
@State var scrollProxy: ScrollViewProxy?
|
|
||||||
@State var metaColor: Color
|
|
||||||
@State private var showFullScreenImage = false
|
@State private var showFullScreenImage = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -26,19 +23,21 @@ struct CIImageView: View {
|
|||||||
if let uiImage = getLoadedImage(file) {
|
if let uiImage = getLoadedImage(file) {
|
||||||
imageView(uiImage)
|
imageView(uiImage)
|
||||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage, scrollProxy: scrollProxy)
|
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
||||||
}
|
}
|
||||||
.onTapGesture { showFullScreenImage = true }
|
.onTapGesture { showFullScreenImage = true }
|
||||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
let uiImage = UIImage(data: data) {
|
showFullScreenImage = false
|
||||||
imageView(uiImage)
|
}
|
||||||
|
} else if let preview {
|
||||||
|
imageView(preview)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let file = file {
|
if let file = file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
case .rcvInvitation:
|
case .rcvInvitation, .rcvAborted:
|
||||||
Task {
|
Task {
|
||||||
if let user = m.currentUser {
|
if let user = m.currentUser {
|
||||||
await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile)
|
await receiveFile(user: user, fileId: file.fileId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .rcvAccepted:
|
case .rcvAccepted:
|
||||||
@@ -53,10 +52,31 @@ struct CIImageView: View {
|
|||||||
title: "Waiting for image",
|
title: "Waiting for image",
|
||||||
message: "Image will be received when your contact is online, please wait or check later!"
|
message: "Image will be received when your contact is online, please wait or check later!"
|
||||||
)
|
)
|
||||||
|
case .local: ()
|
||||||
}
|
}
|
||||||
case .rcvTransfer: () // ?
|
case .rcvTransfer: () // ?
|
||||||
case .rcvComplete: () // ?
|
case .rcvComplete: () // ?
|
||||||
case .rcvCancelled: () // TODO
|
case .rcvCancelled: () // TODO
|
||||||
|
case let .rcvError(rcvFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndError(sndFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,14 +86,13 @@ struct CIImageView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func imageView(_ img: UIImage) -> some View {
|
private func imageView(_ img: UIImage) -> some View {
|
||||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : img.imageData == nil ? .infinity : maxWidth
|
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { imgWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
if img.imageData == nil {
|
if img.imageData == nil {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(maxWidth: w)
|
.frame(width: w)
|
||||||
} else {
|
} else {
|
||||||
SwiftyGif(image: img)
|
SwiftyGif(image: img)
|
||||||
.frame(width: w, height: w * img.size.height / img.size.width)
|
.frame(width: w, height: w * img.size.height / img.size.width)
|
||||||
@@ -90,18 +109,22 @@ struct CIImageView: View {
|
|||||||
switch file.fileProtocol {
|
switch file.fileProtocol {
|
||||||
case .xftp: progressView()
|
case .xftp: progressView()
|
||||||
case .smp: EmptyView()
|
case .smp: EmptyView()
|
||||||
|
case .local: EmptyView()
|
||||||
}
|
}
|
||||||
case .sndTransfer: progressView()
|
case .sndTransfer: progressView()
|
||||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .sndError: fileIcon("xmark", 10, 13)
|
case .sndError: fileIcon("xmark", 10, 13)
|
||||||
|
case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||||
case .rcvTransfer: progressView()
|
case .rcvTransfer: progressView()
|
||||||
|
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||||
|
case .rcvComplete: EmptyView()
|
||||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .rcvError: fileIcon("xmark", 10, 13)
|
case .rcvError: fileIcon("xmark", 10, 13)
|
||||||
|
case .rcvWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
case .invalid: fileIcon("questionmark", 10, 13)
|
case .invalid: fileIcon("questionmark", 10, 13)
|
||||||
default: EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +134,7 @@ struct CIImageView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(metaColor)
|
.foregroundColor(.white)
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CIInvalidJSONView: View {
|
struct CIInvalidJSONView: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var json: String
|
var json: String
|
||||||
@State private var showJSON = false
|
@State private var showJSON = false
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ struct CIInvalidJSONView: View {
|
|||||||
.cornerRadius(18)
|
.cornerRadius(18)
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onTapGesture { showJSON = true }
|
.onTapGesture { showJSON = true }
|
||||||
.sheet(isPresented: $showJSON) {
|
.appSheet(isPresented: $showJSON) {
|
||||||
invalidJSONView(json)
|
invalidJSONView(json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +45,7 @@ func invalidJSONView(_ json: String) -> some View {
|
|||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CIInvalidJSONView_Previews: PreviewProvider {
|
struct CIInvalidJSONView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct CILinkView: View {
|
struct CILinkView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let linkPreview: LinkPreview
|
let linkPreview: LinkPreview
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -32,7 +32,7 @@ struct CILinkView: View {
|
|||||||
Text(linkPreview.uri.absoluteString)
|
Text(linkPreview.uri.absoluteString)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIMemberCreatedContactView: View {
|
struct CIMemberCreatedContactView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -43,12 +44,12 @@ struct CIMemberCreatedContactView: View {
|
|||||||
r = r
|
r = r
|
||||||
+ Text(openText)
|
+ Text(openText)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
}
|
}
|
||||||
r = r + chatItem.timestampText
|
r = r + chatItem.timestampText
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
return r.font(.caption)
|
return r.font(.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +57,11 @@ struct CIMemberCreatedContactView: View {
|
|||||||
if let member = chatItem.memberDisplayName {
|
if let member = chatItem.memberDisplayName {
|
||||||
return Text(member + " " + chatItem.content.text + " ")
|
return Text(member + " " + chatItem.content.text + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
return Text(chatItem.content.text + " ")
|
return Text(chatItem.content.text + " ")
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIMetaView: View {
|
struct CIMetaView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var metaColor = Color.secondary
|
var metaColor: Color
|
||||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||||
|
var showStatus = true
|
||||||
|
var showEdited = true
|
||||||
|
|
||||||
|
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if chatItem.isDeletedContent {
|
if chatItem.isDeletedContent {
|
||||||
@@ -25,24 +30,24 @@ struct CIMetaView: View {
|
|||||||
switch meta.itemStatus {
|
switch meta.itemStatus {
|
||||||
case let .sndSent(sndProgress):
|
case let .sndSent(sndProgress):
|
||||||
switch sndProgress {
|
switch sndProgress {
|
||||||
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent)
|
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||||
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent)
|
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||||
}
|
}
|
||||||
case let .sndRcvd(_, sndProgress):
|
case let .sndRcvd(_, sndProgress):
|
||||||
switch sndProgress {
|
switch sndProgress {
|
||||||
case .complete:
|
case .complete:
|
||||||
ZStack {
|
ZStack {
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1)
|
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)
|
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||||
}
|
}
|
||||||
case .partial:
|
case .partial:
|
||||||
ZStack {
|
ZStack {
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1)
|
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)
|
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor)
|
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,9 +59,20 @@ enum SentCheckmark {
|
|||||||
case rcvd2
|
case rcvd2
|
||||||
}
|
}
|
||||||
|
|
||||||
func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color = .clear, transparent: Bool = false, sent: SentCheckmark? = nil) -> Text {
|
func ciMetaText(
|
||||||
|
_ meta: CIMeta,
|
||||||
|
chatTTL: Int?,
|
||||||
|
encrypted: Bool?,
|
||||||
|
color: Color = .clear,
|
||||||
|
primaryColor: Color = .accentColor,
|
||||||
|
transparent: Bool = false,
|
||||||
|
sent: SentCheckmark? = nil,
|
||||||
|
showStatus: Bool = true,
|
||||||
|
showEdited: Bool = true,
|
||||||
|
showViaProxy: Bool
|
||||||
|
) -> Text {
|
||||||
var r = Text("")
|
var r = Text("")
|
||||||
if meta.itemEdited {
|
if showEdited, meta.itemEdited {
|
||||||
r = r + statusIconText("pencil", color)
|
r = r + statusIconText("pencil", color)
|
||||||
}
|
}
|
||||||
if meta.disappearing {
|
if meta.disappearing {
|
||||||
@@ -67,19 +83,24 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, color: Color =
|
|||||||
}
|
}
|
||||||
r = r + Text(" ")
|
r = r + Text(" ")
|
||||||
}
|
}
|
||||||
if let (icon, statusColor) = meta.statusIcon(color) {
|
if showViaProxy, meta.sentViaProxy == true {
|
||||||
let t = Text(Image(systemName: icon)).font(.caption2)
|
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2)
|
||||||
let gap = Text(" ").kerning(-1.25)
|
}
|
||||||
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
if showStatus {
|
||||||
switch sent {
|
if let (icon, statusColor) = meta.statusIcon(color, primaryColor) {
|
||||||
case nil: r = r + t1
|
let t = Text(Image(systemName: icon)).font(.caption2)
|
||||||
case .sent: r = r + t1 + gap
|
let gap = Text(" ").kerning(-1.25)
|
||||||
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
|
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67))
|
||||||
case .rcvd2: r = r + gap + t1
|
switch sent {
|
||||||
|
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 + Text(" ")
|
||||||
|
} else if !meta.disappearing {
|
||||||
|
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
|
||||||
}
|
}
|
||||||
r = r + Text(" ")
|
|
||||||
} else if !meta.disappearing {
|
|
||||||
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ")
|
|
||||||
}
|
}
|
||||||
if let enc = encrypted {
|
if let enc = encrypted {
|
||||||
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
|
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ")
|
||||||
@@ -93,15 +114,16 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct CIMetaView_Previews: PreviewProvider {
|
struct CIMetaView_Previews: PreviewProvider {
|
||||||
|
static let metaColor = Color.secondary
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true), metaColor: metaColor)
|
||||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor)
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 100))
|
.previewLayout(.fixed(width: 360, height: 100))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ let decryptErrorReason: LocalizedStringKey = "It can happen when you or your con
|
|||||||
|
|
||||||
struct CIRcvDecryptionError: View {
|
struct CIRcvDecryptionError: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var msgDecryptError: MsgDecryptError
|
var msgDecryptError: MsgDecryptError
|
||||||
var msgCount: UInt32
|
var msgCount: UInt32
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@State private var alert: CIRcvDecryptionErrorAlert?
|
@State private var alert: CIRcvDecryptionErrorAlert?
|
||||||
|
|
||||||
|
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||||
|
|
||||||
enum CIRcvDecryptionErrorAlert: Identifiable {
|
enum CIRcvDecryptionErrorAlert: Identifiable {
|
||||||
case syncAllowedAlert(_ syncConnection: () -> Void)
|
case syncAllowedAlert(_ syncConnection: () -> Void)
|
||||||
case syncNotSupportedContactAlert
|
case syncNotSupportedContactAlert
|
||||||
@@ -112,18 +115,18 @@ struct CIRcvDecryptionError: View {
|
|||||||
}
|
}
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
||||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ Text("Fix connection")
|
+ Text("Fix connection")
|
||||||
.foregroundColor(syncSupported ? .accentColor : .secondary)
|
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.onTapGesture(perform: { onClick() })
|
.onTapGesture(perform: { onClick() })
|
||||||
@@ -140,10 +143,10 @@ struct CIRcvDecryptionError: View {
|
|||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.italic()
|
.italic()
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
|
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.onTapGesture(perform: { onClick() })
|
.onTapGesture(perform: { onClick() })
|
||||||
|
|||||||
@@ -13,56 +13,55 @@ import Combine
|
|||||||
|
|
||||||
struct CIVideoView: View {
|
struct CIVideoView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
private let chatItem: ChatItem
|
private let chatItem: ChatItem
|
||||||
private let image: String
|
private let preview: UIImage?
|
||||||
@State private var duration: Int
|
@State private var duration: Int
|
||||||
@State private var progress: Int = 0
|
@State private var progress: Int = 0
|
||||||
@State private var videoPlaying: Bool = false
|
@State private var videoPlaying: Bool = false
|
||||||
private let maxWidth: CGFloat
|
private let maxWidth: CGFloat
|
||||||
@Binding private var videoWidth: CGFloat?
|
private var videoWidth: CGFloat?
|
||||||
@State private var scrollProxy: ScrollViewProxy?
|
|
||||||
@State private var preview: UIImage? = nil
|
|
||||||
@State private var player: AVPlayer?
|
@State private var player: AVPlayer?
|
||||||
@State private var fullPlayer: AVPlayer?
|
@State private var fullPlayer: AVPlayer?
|
||||||
@State private var url: URL?
|
@State private var url: URL?
|
||||||
|
@State private var urlDecrypted: URL?
|
||||||
|
@State private var decryptionInProgress: Bool = false
|
||||||
@State private var showFullScreenPlayer = false
|
@State private var showFullScreenPlayer = false
|
||||||
@State private var timeObserver: Any? = nil
|
@State private var timeObserver: Any? = nil
|
||||||
@State private var fullScreenTimeObserver: Any? = nil
|
@State private var fullScreenTimeObserver: Any? = nil
|
||||||
@State private var publisher: AnyCancellable? = nil
|
@State private var publisher: AnyCancellable? = nil
|
||||||
|
|
||||||
init(chatItem: ChatItem, image: String, duration: Int, maxWidth: CGFloat, videoWidth: Binding<CGFloat?>, scrollProxy: ScrollViewProxy?) {
|
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) {
|
||||||
self.chatItem = chatItem
|
self.chatItem = chatItem
|
||||||
self.image = image
|
self.preview = preview
|
||||||
self._duration = State(initialValue: duration)
|
self._duration = State(initialValue: duration)
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
self._videoWidth = videoWidth
|
self.videoWidth = videoWidth
|
||||||
self.scrollProxy = scrollProxy
|
|
||||||
if let url = getLoadedVideo(chatItem.file) {
|
if let url = getLoadedVideo(chatItem.file) {
|
||||||
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(url, false))
|
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
|
||||||
self._fullPlayer = State(initialValue: AVPlayer(url: url))
|
self._urlDecrypted = State(initialValue: decrypted)
|
||||||
|
if let decrypted = decrypted {
|
||||||
|
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
|
||||||
|
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
|
||||||
|
}
|
||||||
self._url = State(initialValue: url)
|
self._url = State(initialValue: url)
|
||||||
}
|
}
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
|
||||||
let uiImage = UIImage(data: data) {
|
|
||||||
self._preview = State(initialValue: uiImage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let file = chatItem.file
|
let file = chatItem.file
|
||||||
ZStack {
|
ZStack {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
if let file = file, let preview = preview, let player = player, let url = url {
|
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
||||||
videoView(player, url, file, preview, duration)
|
videoView(player, decrypted, file, preview, duration)
|
||||||
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
|
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
||||||
let uiImage = UIImage(data: data) {
|
videoViewEncrypted(file, defaultPreview, duration)
|
||||||
imageView(uiImage)
|
} else if let preview {
|
||||||
|
imageView(preview)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let file = file {
|
if let file = file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
case .rcvInvitation:
|
case .rcvInvitation, .rcvAborted:
|
||||||
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
|
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||||
case .rcvAccepted:
|
case .rcvAccepted:
|
||||||
switch file.fileProtocol {
|
switch file.fileProtocol {
|
||||||
case .xftp:
|
case .xftp:
|
||||||
@@ -75,6 +74,7 @@ struct CIVideoView: View {
|
|||||||
title: "Waiting for video",
|
title: "Waiting for video",
|
||||||
message: "Video will be received when your contact is online, please wait or check later!"
|
message: "Video will be received when your contact is online, please wait or check later!"
|
||||||
)
|
)
|
||||||
|
case .local: ()
|
||||||
}
|
}
|
||||||
case .rcvTransfer: () // ?
|
case .rcvTransfer: () // ?
|
||||||
case .rcvComplete: () // ?
|
case .rcvComplete: () // ?
|
||||||
@@ -86,9 +86,9 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
durationProgress()
|
durationProgress()
|
||||||
}
|
}
|
||||||
if let file = file, case .rcvInvitation = file.fileStatus {
|
if let file = file, showDownloadButton(file.fileStatus) {
|
||||||
Button {
|
Button {
|
||||||
receiveFileIfValidSize(file: file, encrypted: false, receiveFile: receiveFile)
|
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill")
|
||||||
}
|
}
|
||||||
@@ -96,12 +96,56 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
|
||||||
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
switch fileStatus {
|
||||||
DispatchQueue.main.async { videoWidth = w }
|
case .rcvInvitation: true
|
||||||
|
case .rcvAborted: true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func videoViewEncrypted(_ file: CIFile, _ defaultPreview: UIImage, _ duration: Int) -> some View {
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete
|
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||||
|
imageView(defaultPreview)
|
||||||
|
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||||
|
if let decrypted = urlDecrypted {
|
||||||
|
fullScreenPlayer(decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
decrypt(file: file) {
|
||||||
|
showFullScreenPlayer = urlDecrypted != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
showFullScreenPlayer = false
|
||||||
|
}
|
||||||
|
if !decryptionInProgress {
|
||||||
|
Button {
|
||||||
|
decrypt(file: file) {
|
||||||
|
if urlDecrypted != nil {
|
||||||
|
videoPlaying = true
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||||
|
}
|
||||||
|
.disabled(!canBePlayed)
|
||||||
|
} else {
|
||||||
|
videoDecryptionProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func videoView(_ player: AVPlayer, _ url: URL, _ file: CIFile, _ preview: UIImage, _ duration: Int) -> some View {
|
||||||
|
let w = preview.size.width <= preview.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
|
return ZStack(alignment: .topTrailing) {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||||
VideoPlayerView(player: player, url: url, showControls: false)
|
VideoPlayerView(player: player, url: url, showControls: false)
|
||||||
.frame(width: w, height: w * preview.size.height / preview.size.width)
|
.frame(width: w, height: w * preview.size.height / preview.size.width)
|
||||||
.onChange(of: m.stopPreviousRecPlay) { playingUrl in
|
.onChange(of: m.stopPreviousRecPlay) { playingUrl in
|
||||||
@@ -125,6 +169,9 @@ struct CIVideoView: View {
|
|||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
|
showFullScreenPlayer = false
|
||||||
|
}
|
||||||
if !videoPlaying {
|
if !videoPlaying {
|
||||||
Button {
|
Button {
|
||||||
m.stopPreviousRecPlay = url
|
m.stopPreviousRecPlay = url
|
||||||
@@ -135,7 +182,7 @@ struct CIVideoView: View {
|
|||||||
.disabled(!canBePlayed)
|
.disabled(!canBePlayed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadingIndicator()
|
fileStatusIcon()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
addObserver(player, url)
|
addObserver(player, url)
|
||||||
@@ -159,6 +206,16 @@ struct CIVideoView: View {
|
|||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.tint(color)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Color.black.opacity(0.35))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
private func durationProgress() -> some View {
|
private func durationProgress() -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(durationText(videoPlaying ? progress : duration))")
|
Text("\(durationText(videoPlaying ? progress : duration))")
|
||||||
@@ -184,33 +241,49 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func imageView(_ img: UIImage) -> some View {
|
private func imageView(_ img: UIImage) -> some View {
|
||||||
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : .infinity
|
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
|
||||||
DispatchQueue.main.async { videoWidth = w }
|
|
||||||
return ZStack(alignment: .topTrailing) {
|
return ZStack(alignment: .topTrailing) {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(maxWidth: w)
|
.frame(width: w)
|
||||||
loadingIndicator()
|
fileStatusIcon()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func loadingIndicator() -> some View {
|
@ViewBuilder private func fileStatusIcon() -> some View {
|
||||||
if let file = chatItem.file {
|
if let file = chatItem.file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
case .sndStored:
|
case .sndStored:
|
||||||
switch file.fileProtocol {
|
switch file.fileProtocol {
|
||||||
case .xftp: progressView()
|
case .xftp: progressView()
|
||||||
case .smp: EmptyView()
|
case .smp: EmptyView()
|
||||||
|
case .local: EmptyView()
|
||||||
}
|
}
|
||||||
case let .sndTransfer(sndProgress, sndTotal):
|
case let .sndTransfer(sndProgress, sndTotal):
|
||||||
switch file.fileProtocol {
|
switch file.fileProtocol {
|
||||||
case .xftp: progressCircle(sndProgress, sndTotal)
|
case .xftp: progressCircle(sndProgress, sndTotal)
|
||||||
case .smp: progressView()
|
case .smp: progressView()
|
||||||
|
case .local: EmptyView()
|
||||||
}
|
}
|
||||||
case .sndComplete: fileIcon("checkmark", 10, 13)
|
case .sndComplete: fileIcon("checkmark", 10, 13)
|
||||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .sndError: fileIcon("xmark", 10, 13)
|
case let .sndError(sndFileError):
|
||||||
|
fileIcon("xmark", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
|
||||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||||
case let .rcvTransfer(rcvProgress, rcvTotal):
|
case let .rcvTransfer(rcvProgress, rcvTotal):
|
||||||
@@ -219,10 +292,26 @@ struct CIVideoView: View {
|
|||||||
} else {
|
} else {
|
||||||
progressView()
|
progressView()
|
||||||
}
|
}
|
||||||
|
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||||
|
case .rcvComplete: EmptyView()
|
||||||
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
case .rcvCancelled: fileIcon("xmark", 10, 13)
|
||||||
case .rcvError: fileIcon("xmark", 10, 13)
|
case let .rcvError(rcvFileError):
|
||||||
|
fileIcon("xmark", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .invalid: fileIcon("questionmark", 10, 13)
|
case .invalid: fileIcon("questionmark", 10, 13)
|
||||||
default: EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,10 +346,10 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO encrypt: where file size is checked?
|
// TODO encrypt: where file size is checked?
|
||||||
private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
|
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
|
||||||
Task {
|
Task {
|
||||||
if let user = m.currentUser {
|
if let user = m.currentUser {
|
||||||
await receiveFile(user, file.fileId, encrypted, false)
|
await receiveFile(user, file.fileId, false, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,6 +412,22 @@ struct CIVideoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func decrypt(file: CIFile, completed: (() -> Void)? = nil) {
|
||||||
|
if decryptionInProgress { return }
|
||||||
|
decryptionInProgress = true
|
||||||
|
Task {
|
||||||
|
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
|
||||||
|
await MainActor.run {
|
||||||
|
if let decrypted = urlDecrypted {
|
||||||
|
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||||
|
fullPlayer = AVPlayer(url: decrypted)
|
||||||
|
}
|
||||||
|
decryptionInProgress = true
|
||||||
|
completed?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func addObserver(_ player: AVPlayer, _ url: URL) {
|
private func addObserver(_ player: AVPlayer, _ url: URL) {
|
||||||
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
|
timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.01, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), queue: .main) { time in
|
||||||
if let item = player.currentItem {
|
if let item = player.currentItem {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIVoiceView: View {
|
struct CIVoiceView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
let recordingFile: CIFile?
|
let recordingFile: CIFile?
|
||||||
let duration: Int
|
let duration: Int
|
||||||
@@ -72,7 +73,7 @@ struct CIVoiceView: View {
|
|||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime
|
playbackTime: $playbackTime
|
||||||
)
|
)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playbackSlider() -> some View {
|
private func playbackSlider() -> some View {
|
||||||
@@ -89,10 +90,11 @@ struct CIVoiceView: View {
|
|||||||
allowMenu = true
|
allowMenu = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func metaView() -> some View {
|
private func metaView() -> some View {
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ struct VoiceMessagePlayerTime: View {
|
|||||||
|
|
||||||
struct VoiceMessagePlayer: View {
|
struct VoiceMessagePlayer: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var recordingFile: CIFile?
|
var recordingFile: CIFile?
|
||||||
var recordingTime: TimeInterval
|
var recordingTime: TimeInterval
|
||||||
@@ -134,17 +136,53 @@ struct VoiceMessagePlayer: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
if let recordingFile = recordingFile {
|
if let recordingFile = recordingFile {
|
||||||
switch recordingFile.fileStatus {
|
switch recordingFile.fileStatus {
|
||||||
case .sndStored: playbackButton()
|
case .sndStored:
|
||||||
case .sndTransfer: playbackButton()
|
if recordingFile.fileProtocol == .local {
|
||||||
|
playbackButton()
|
||||||
|
} else {
|
||||||
|
loadingIcon()
|
||||||
|
}
|
||||||
|
case .sndTransfer: loadingIcon()
|
||||||
case .sndComplete: playbackButton()
|
case .sndComplete: playbackButton()
|
||||||
case .sndCancelled: playbackButton()
|
case .sndCancelled: playbackButton()
|
||||||
case .sndError: playbackButton()
|
case let .sndError(sndFileError):
|
||||||
case .rcvInvitation: downloadButton(recordingFile)
|
fileStatusIcon("multiply", 14)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .sndWarning(sndFileError):
|
||||||
|
fileStatusIcon("exclamationmark.triangle.fill", 16)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(sndFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
|
||||||
case .rcvAccepted: loadingIcon()
|
case .rcvAccepted: loadingIcon()
|
||||||
case .rcvTransfer: loadingIcon()
|
case .rcvTransfer: loadingIcon()
|
||||||
|
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
|
||||||
case .rcvComplete: playbackButton()
|
case .rcvComplete: playbackButton()
|
||||||
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case let .rcvError(rcvFileError):
|
||||||
|
fileStatusIcon("multiply", 14)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("File error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
case let .rcvWarning(rcvFileError):
|
||||||
|
fileStatusIcon("exclamationmark.triangle.fill", 16)
|
||||||
|
.onTapGesture {
|
||||||
|
AlertManager.shared.showAlert(Alert(
|
||||||
|
title: Text("Temporary file error"),
|
||||||
|
message: Text(rcvFileError.errorInfo)
|
||||||
|
))
|
||||||
|
}
|
||||||
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -180,26 +218,26 @@ struct VoiceMessagePlayer: View {
|
|||||||
startPlayback(recordingSource)
|
startPlayback(recordingSource)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
case .playing:
|
case .playing:
|
||||||
Button {
|
Button {
|
||||||
audioPlayer?.pause()
|
audioPlayer?.pause()
|
||||||
playbackState = .paused
|
playbackState = .paused
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("pause.fill")
|
playPauseIcon("pause.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
case .paused:
|
case .paused:
|
||||||
Button {
|
Button {
|
||||||
audioPlayer?.play()
|
audioPlayer?.play()
|
||||||
playbackState = .playing
|
playbackState = .playing
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -208,7 +246,7 @@ struct VoiceMessagePlayer: View {
|
|||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.padding(.leading, image == "play.fill" ? 4 : 0)
|
.padding(.leading, image == "play.fill" ? 4 : 0)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
if recordingTime > 0 {
|
if recordingTime > 0 {
|
||||||
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
||||||
@@ -217,19 +255,20 @@ struct VoiceMessagePlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func downloadButton(_ recordingFile: CIFile) -> some View {
|
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
if let user = chatModel.currentUser {
|
if let user = chatModel.currentUser {
|
||||||
await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon(icon, theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ProgressCircle: View {
|
private struct ProgressCircle: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var length: TimeInterval
|
var length: TimeInterval
|
||||||
@Binding var progress: TimeInterval?
|
@Binding var progress: TimeInterval?
|
||||||
|
|
||||||
@@ -237,7 +276,7 @@ struct VoiceMessagePlayer: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
.trim(from: 0, to: ((progress ?? TimeInterval(0)) / length))
|
||||||
.stroke(
|
.stroke(
|
||||||
Color.accentColor,
|
theme.colors.primary,
|
||||||
style: StrokeStyle(lineWidth: 3)
|
style: StrokeStyle(lineWidth: 3)
|
||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
@@ -245,11 +284,22 @@ struct VoiceMessagePlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fileStatusIcon(_ image: String, _ size: CGFloat) -> some View {
|
||||||
|
Image(systemName: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
private func loadingIcon() -> some View {
|
private func loadingIcon() -> some View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30, height: 30)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56, height: 56)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,21 +10,21 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct DeletedItemView: View {
|
struct DeletedItemView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .bottom, spacing: 0) {
|
HStack(alignment: .bottom, spacing: 0) {
|
||||||
Text(chatItem.content.text)
|
Text(chatItem.content.text)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.italic()
|
.italic()
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
.cornerRadius(18)
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct EmojiItemView: View {
|
struct EmojiItemView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -18,7 +19,7 @@ struct EmojiItemView: View {
|
|||||||
emojiText(chatItem.content.text)
|
emojiText(chatItem.content.text)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct FramedCIVoiceView: View {
|
struct FramedCIVoiceView: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
let recordingFile: CIFile?
|
let recordingFile: CIFile?
|
||||||
let duration: Int
|
let duration: Int
|
||||||
@@ -42,7 +43,7 @@ struct FramedCIVoiceView: View {
|
|||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime
|
playbackTime: $playbackTime
|
||||||
)
|
)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.frame(width: 50, alignment: .leading)
|
.frame(width: 50, alignment: .leading)
|
||||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||||
playbackSlider()
|
playbackSlider()
|
||||||
|
|||||||
@@ -9,26 +9,24 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
|
|
||||||
let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
|
|
||||||
private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.11)
|
|
||||||
private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09)
|
|
||||||
|
|
||||||
struct FramedItemView: View {
|
struct FramedItemView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem>
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
var preview: UIImage?
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
var maxWidth: CGFloat = .infinity
|
var maxWidth: CGFloat = .infinity
|
||||||
@State var scrollProxy: ScrollViewProxy? = nil
|
|
||||||
@State var msgWidth: CGFloat = 0
|
@State var msgWidth: CGFloat = 0
|
||||||
@State var imgWidth: CGFloat? = nil
|
var imgWidth: CGFloat? = nil
|
||||||
@State var videoWidth: CGFloat? = nil
|
var videoWidth: CGFloat? = nil
|
||||||
@State var metaColor = Color.secondary
|
@State private var useWhiteMetaColor: Bool = false
|
||||||
@State var showFullScreenImage = false
|
@State var showFullScreenImage = false
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
|
@State private var showSecrets = false
|
||||||
|
@State private var showQuoteSecrets = false
|
||||||
|
|
||||||
@Binding var audioPlayer: AudioPlayer?
|
@Binding var audioPlayer: AudioPlayer?
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
@Binding var playbackState: VoiceMessagePlaybackState
|
||||||
@Binding var playbackTime: TimeInterval?
|
@Binding var playbackTime: TimeInterval?
|
||||||
@@ -42,7 +40,9 @@ struct FramedItemView: View {
|
|||||||
framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic())
|
framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic())
|
||||||
case .blocked:
|
case .blocked:
|
||||||
framedItemHeader(icon: "hand.raised", caption: Text("blocked").italic())
|
framedItemHeader(icon: "hand.raised", caption: Text("blocked").italic())
|
||||||
default:
|
case .blockedByAdmin:
|
||||||
|
framedItemHeader(icon: "hand.raised", caption: Text("blocked by admin").italic())
|
||||||
|
case .deleted:
|
||||||
framedItemHeader(icon: "trash", caption: Text("marked deleted").italic())
|
framedItemHeader(icon: "trash", caption: Text("marked deleted").italic())
|
||||||
}
|
}
|
||||||
} else if chatItem.meta.isLive {
|
} else if chatItem.meta.isLive {
|
||||||
@@ -52,39 +52,44 @@ struct FramedItemView: View {
|
|||||||
if let qi = chatItem.quotedItem {
|
if let qi = chatItem.quotedItem {
|
||||||
ciQuoteView(qi)
|
ciQuoteView(qi)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let proxy = scrollProxy,
|
if let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
||||||
let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) {
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
proxy.scrollTo(ci.viewId, anchor: .bottom)
|
scrollModel.scrollToItem(id: ci.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if let itemForwarded = chatItem.meta.itemForwarded {
|
||||||
|
framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: framedMsgContentView)
|
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: framedMsgContentView)
|
||||||
.padding(chatItem.content.msgContent != nil ? 0 : 4)
|
.padding(chatItem.content.msgContent != nil ? 0 : 4)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
}
|
}
|
||||||
.onPreferenceChange(MetaColorPreferenceKey.self) { metaColor = $0 }
|
|
||||||
|
|
||||||
if chatItem.content.msgContent != nil {
|
if chatItem.content.msgContent != nil {
|
||||||
CIMetaView(chat: chat, chatItem: chatItem, metaColor: metaColor)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: useWhiteMetaColor ? Color.white : theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
.accessibilityLabel("")
|
.accessibilityLabel("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme))
|
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme))
|
||||||
.cornerRadius(18)
|
.cornerRadius(18)
|
||||||
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
|
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
|
||||||
|
|
||||||
switch chatItem.meta.itemStatus {
|
if let (title, text) = chatItem.meta.itemStatus.statusInfo {
|
||||||
case .sndErrorAuth:
|
v.onTapGesture {
|
||||||
v.onTapGesture { msgDeliveryError("Most likely this contact has deleted the connection with you.") }
|
AlertManager.shared.showAlert(
|
||||||
case let .sndError(agentError):
|
Alert(
|
||||||
v.onTapGesture { msgDeliveryError("Unexpected error: \(agentError)") }
|
title: Text(title),
|
||||||
default: v
|
message: Text(text)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,29 +106,33 @@ struct FramedItemView: View {
|
|||||||
.padding(.bottom, 2)
|
.padding(.bottom, 2)
|
||||||
} else {
|
} else {
|
||||||
switch (chatItem.content.msgContent) {
|
switch (chatItem.content.msgContent) {
|
||||||
case let .image(text, image):
|
case let .image(text, _):
|
||||||
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy, metaColor: metaColor)
|
CIImageView(chatItem: chatItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
if text == "" && !chatItem.meta.isLive {
|
if text == "" && !chatItem.meta.isLive {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: 0, height: 0)
|
.frame(width: 0, height: 0)
|
||||||
.preference(
|
.onAppear {
|
||||||
key: MetaColorPreferenceKey.self,
|
useWhiteMetaColor = true
|
||||||
value: .white
|
}
|
||||||
)
|
.onDisappear {
|
||||||
|
useWhiteMetaColor = false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ciMsgContentView(chatItem)
|
ciMsgContentView(chatItem)
|
||||||
}
|
}
|
||||||
case let .video(text, image, duration):
|
case let .video(text, _, duration):
|
||||||
CIVideoView(chatItem: chatItem, image: image, duration: duration, maxWidth: maxWidth, videoWidth: $videoWidth, scrollProxy: scrollProxy)
|
CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
if text == "" && !chatItem.meta.isLive {
|
if text == "" && !chatItem.meta.isLive {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: 0, height: 0)
|
.frame(width: 0, height: 0)
|
||||||
.preference(
|
.onAppear {
|
||||||
key: MetaColorPreferenceKey.self,
|
useWhiteMetaColor = true
|
||||||
value: .white
|
}
|
||||||
)
|
.onDisappear {
|
||||||
|
useWhiteMetaColor = false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ciMsgContentView(chatItem)
|
ciMsgContentView(chatItem)
|
||||||
}
|
}
|
||||||
@@ -149,15 +158,8 @@ struct FramedItemView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func msgDeliveryError(_ err: LocalizedStringKey) {
|
|
||||||
AlertManager.shared.showAlertMsg(
|
|
||||||
title: "Message delivery error",
|
|
||||||
message: err
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text) -> some View {
|
@ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View {
|
||||||
let v = HStack(spacing: 6) {
|
let v = HStack(spacing: 6) {
|
||||||
if let icon = icon {
|
if let icon = icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
@@ -169,13 +171,13 @@ struct FramedItemView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
.padding(.bottom, chatItem.quotedItem == nil ? 6 : 0) // TODO think how to regroup
|
.padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
.frame(minWidth: msgWidth, alignment: .leading)
|
.frame(minWidth: msgWidth, alignment: .leading)
|
||||||
.background(chatItemFrameContextColor(chatItem, colorScheme))
|
.background(chatItemFrameContextColor(chatItem, theme))
|
||||||
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
||||||
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
||||||
} else {
|
} else {
|
||||||
@@ -227,7 +229,7 @@ struct FramedItemView: View {
|
|||||||
// if enable this always, size of the framed voice message item will be incorrect after end of playback
|
// if enable this always, size of the framed voice message item will be incorrect after end of playback
|
||||||
.overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } }
|
.overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } }
|
||||||
.frame(minWidth: msgWidth, alignment: .leading)
|
.frame(minWidth: msgWidth, alignment: .leading)
|
||||||
.background(chatItemFrameContextColor(chatItem, colorScheme))
|
.background(chatItemFrameContextColor(chatItem, theme))
|
||||||
|
|
||||||
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
|
||||||
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
v.frame(maxWidth: mediaWidth, alignment: .leading)
|
||||||
@@ -240,22 +242,28 @@ struct FramedItemView: View {
|
|||||||
Group {
|
Group {
|
||||||
if let sender = qi.getSender(membership()) {
|
if let sender = qi.getSender(membership()) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(sender).font(.caption).foregroundColor(.secondary)
|
Text(sender)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
ciQuotedMsgTextView(qi, lines: 2)
|
ciQuotedMsgTextView(qi, lines: 2)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ciQuotedMsgTextView(qi, lines: 3)
|
ciQuotedMsgTextView(qi, lines: 3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View {
|
private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View {
|
||||||
MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText)
|
toggleSecrets(qi.formattedText, $showQuoteSecrets,
|
||||||
.lineLimit(lines)
|
MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, showSecrets: showQuoteSecrets)
|
||||||
.font(.subheadline)
|
.lineLimit(lines)
|
||||||
.padding(.bottom, 6)
|
.font(.subheadline)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ciQuoteIconView(_ image: String) -> some View {
|
private func ciQuoteIconView(_ image: String) -> some View {
|
||||||
@@ -278,13 +286,15 @@ struct FramedItemView: View {
|
|||||||
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View {
|
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View {
|
||||||
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
|
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
|
||||||
let rtl = isRightToLeft(text)
|
let rtl = isRightToLeft(text)
|
||||||
let v = MsgContentView(
|
let ft = text == "" ? [] : ci.formattedText
|
||||||
|
let v = toggleSecrets(ft, $showSecrets, MsgContentView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
text: text,
|
text: text,
|
||||||
formattedText: text == "" ? [] : ci.formattedText,
|
formattedText: ft,
|
||||||
meta: ci.meta,
|
meta: ci.meta,
|
||||||
rightToLeft: rtl
|
rightToLeft: rtl,
|
||||||
)
|
showSecrets: showSecrets
|
||||||
|
))
|
||||||
.multilineTextAlignment(rtl ? .trailing : .leading)
|
.multilineTextAlignment(rtl ? .trailing : .leading)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -298,7 +308,7 @@ struct FramedItemView: View {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
|
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
|
||||||
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
|
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
|
||||||
.overlay(DetermineWidth())
|
.overlay(DetermineWidth())
|
||||||
@@ -318,6 +328,14 @@ struct FramedItemView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
|
||||||
|
if let ft = ft, ft.contains(where: { $0.isSecret }) {
|
||||||
|
v.onTapGesture { showSecrets.wrappedValue.toggle() }
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func isRightToLeft(_ s: String) -> Bool {
|
func isRightToLeft(_ s: String) -> Bool {
|
||||||
if let lang = CFStringTokenizerCopyBestStringLanguage(s as CFString, CFRange(location: 0, length: min(s.count, 80))) {
|
if let lang = CFStringTokenizerCopyBestStringLanguage(s as CFString, CFRange(location: 0, length: min(s.count, 80))) {
|
||||||
return NSLocale.characterDirection(forLanguage: lang as String) == .rightToLeft
|
return NSLocale.characterDirection(forLanguage: lang as String) == .rightToLeft
|
||||||
@@ -325,38 +343,31 @@ func isRightToLeft(_ s: String) -> Bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MetaColorPreferenceKey: PreferenceKey {
|
|
||||||
static var defaultValue = Color.secondary
|
|
||||||
static func reduce(value: inout Color, nextValue: () -> Color) {
|
|
||||||
value = nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func onlyImageOrVideo(_ ci: ChatItem) -> Bool {
|
func onlyImageOrVideo(_ ci: ChatItem) -> Bool {
|
||||||
if case let .image(text, _) = ci.content.msgContent {
|
if case let .image(text, _) = ci.content.msgContent {
|
||||||
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && text == ""
|
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && ci.meta.itemForwarded == nil && text == ""
|
||||||
} else if case let .video(text, _, _) = ci.content.msgContent {
|
} else if case let .video(text, _, _) = ci.content.msgContent {
|
||||||
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && text == ""
|
return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && ci.meta.itemForwarded == nil && text == ""
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
|
func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ theme: AppTheme) -> Color {
|
||||||
onlyImageOrVideo(ci)
|
onlyImageOrVideo(ci)
|
||||||
? Color.clear
|
? Color.clear
|
||||||
: chatItemFrameColor(ci, colorScheme)
|
: chatItemFrameColor(ci, theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
|
func chatItemFrameColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
|
||||||
ci.chatDir.sent
|
ci.chatDir.sent
|
||||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
? theme.appColors.sentMessage
|
||||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
: theme.appColors.receivedMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatItemFrameContextColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
|
func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color {
|
||||||
ci.chatDir.sent
|
ci.chatDir.sent
|
||||||
? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark)
|
? theme.appColors.sentQuote
|
||||||
: Color(uiColor: .quaternarySystemFill)
|
: theme.appColors.receivedQuote
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FramedItemView_Previews: PreviewProvider {
|
struct FramedItemView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import AVKit
|
|||||||
|
|
||||||
struct FullScreenMediaView: View {
|
struct FullScreenMediaView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@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
|
||||||
@State var url: URL? = nil
|
@State var url: URL? = nil
|
||||||
@Binding var showView: Bool
|
@Binding var showView: Bool
|
||||||
@State var scrollProxy: ScrollViewProxy?
|
|
||||||
@State private var showNext = false
|
@State private var showNext = false
|
||||||
@State private var nextImage: UIImage?
|
@State private var nextImage: UIImage?
|
||||||
@State private var nextPlayer: AVPlayer?
|
@State private var nextPlayer: AVPlayer?
|
||||||
@@ -71,9 +71,7 @@ struct FullScreenMediaView: View {
|
|||||||
let w = abs(t.width)
|
let w = abs(t.width)
|
||||||
if t.height > 60 && t.height > w * 2 {
|
if t.height > 60 && t.height > w * 2 {
|
||||||
showView = false
|
showView = false
|
||||||
if let proxy = scrollProxy {
|
scrollModel.scrollToItem(id: chatItem.id)
|
||||||
proxy.scrollTo(chatItem.viewId)
|
|
||||||
}
|
|
||||||
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
|
} else if w > 60 && w > abs(t.height) * 2 && !scrolling {
|
||||||
let previous = t.width > 0
|
let previous = t.width > 0
|
||||||
scrolling = true
|
scrolling = true
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct IntegrityErrorItemView: View {
|
struct IntegrityErrorItemView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var msgError: MsgErrorType
|
var msgError: MsgErrorType
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ struct IntegrityErrorItemView: View {
|
|||||||
|
|
||||||
struct CIMsgError: View {
|
struct CIMsgError: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var onTap: () -> Void
|
var onTap: () -> Void
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ struct CIMsgError: View {
|
|||||||
Text(chatItem.content.text)
|
Text(chatItem.content.text)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.italic()
|
.italic()
|
||||||
CIMetaView(chat: chat, chatItem: chatItem)
|
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct MarkedDeletedItemView: View {
|
struct MarkedDeletedItemView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
@@ -19,10 +19,10 @@ struct MarkedDeletedItemView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
|
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
.cornerRadius(18)
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@ struct MarkedDeletedItemView: View {
|
|||||||
var i = m.getChatItemIndex(chatItem) {
|
var i = m.getChatItemIndex(chatItem) {
|
||||||
var moderated = 0
|
var moderated = 0
|
||||||
var blocked = 0
|
var blocked = 0
|
||||||
|
var blockedByAdmin = 0
|
||||||
var deleted = 0
|
var deleted = 0
|
||||||
var moderatedBy: Set<String> = []
|
var moderatedBy: Set<String> = []
|
||||||
while i < m.reversedChatItems.count,
|
while i < m.reversedChatItems.count,
|
||||||
@@ -44,16 +45,19 @@ struct MarkedDeletedItemView: View {
|
|||||||
moderated += 1
|
moderated += 1
|
||||||
moderatedBy.insert(byGroupMember.displayName)
|
moderatedBy.insert(byGroupMember.displayName)
|
||||||
case .blocked: blocked += 1
|
case .blocked: blocked += 1
|
||||||
|
case .blockedByAdmin: blockedByAdmin += 1
|
||||||
case .deleted: deleted += 1
|
case .deleted: deleted += 1
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1
|
||||||
}
|
}
|
||||||
let total = moderated + blocked + deleted
|
let total = moderated + blocked + blockedByAdmin + deleted
|
||||||
return total <= 1
|
return total <= 1
|
||||||
? markedDeletedText
|
? markedDeletedText
|
||||||
: total == moderated
|
: total == moderated
|
||||||
? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))"
|
? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))"
|
||||||
: total == blocked
|
: total == blockedByAdmin
|
||||||
|
? "\(total) messages blocked by admin"
|
||||||
|
: total == blocked + blockedByAdmin
|
||||||
? "\(total) messages blocked"
|
? "\(total) messages blocked"
|
||||||
: "\(total) messages marked deleted"
|
: "\(total) messages marked deleted"
|
||||||
} else {
|
} else {
|
||||||
@@ -61,11 +65,14 @@ struct MarkedDeletedItemView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// same texts are in markedDeletedText in ChatPreviewView, but it returns String;
|
||||||
|
// can be refactored into a single function if functions calling these are changed to return same type
|
||||||
var markedDeletedText: LocalizedStringKey {
|
var markedDeletedText: LocalizedStringKey {
|
||||||
switch chatItem.meta.itemDeleted {
|
switch chatItem.meta.itemDeleted {
|
||||||
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
|
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
|
||||||
case .blocked: "blocked"
|
case .blocked: "blocked"
|
||||||
default: "marked deleted"
|
case .blockedByAdmin: "blocked by admin"
|
||||||
|
case .deleted, nil: "marked deleted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||||
|
|
||||||
private let noTyping = Text(" ")
|
private let noTyping = Text(" ")
|
||||||
|
|
||||||
@@ -26,14 +26,18 @@ private func typing(_ w: Font.Weight = .light) -> Text {
|
|||||||
|
|
||||||
struct MsgContentView: View {
|
struct MsgContentView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var text: String
|
var text: String
|
||||||
var formattedText: [FormattedText]? = nil
|
var formattedText: [FormattedText]? = nil
|
||||||
var sender: String? = nil
|
var sender: String? = nil
|
||||||
var meta: CIMeta? = nil
|
var meta: CIMeta? = nil
|
||||||
var rightToLeft = false
|
var rightToLeft = false
|
||||||
|
var showSecrets: Bool
|
||||||
@State private var typingIdx = 0
|
@State private var typingIdx = 0
|
||||||
@State private var timer: Timer?
|
@State private var timer: Timer?
|
||||||
|
|
||||||
|
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if meta?.isLive == true {
|
if meta?.isLive == true {
|
||||||
msgContentView()
|
msgContentView()
|
||||||
@@ -62,7 +66,7 @@ struct MsgContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func msgContentView() -> Text {
|
private func msgContentView() -> Text {
|
||||||
var v = messageText(text, formattedText, sender)
|
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)
|
||||||
if let mt = meta {
|
if let mt = meta {
|
||||||
if mt.isLive {
|
if mt.isLive {
|
||||||
v = v + typingIndicator(mt.recent)
|
v = v + typingIndicator(mt.recent)
|
||||||
@@ -76,22 +80,22 @@ struct MsgContentView: View {
|
|||||||
return (recent ? typingIndicators[typingIdx] : noTyping)
|
return (recent ? typingIndicators[typingIdx] : noTyping)
|
||||||
.font(.body.monospaced())
|
.font(.body.monospaced())
|
||||||
.kerning(-2)
|
.kerning(-2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true)
|
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false) -> Text {
|
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text {
|
||||||
let s = text
|
let s = text
|
||||||
var res: Text
|
var res: Text
|
||||||
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
|
||||||
res = formatText(ft[0], preview)
|
res = formatText(ft[0], preview, showSecret: showSecrets)
|
||||||
var i = 1
|
var i = 1
|
||||||
while i < ft.count {
|
while i < ft.count {
|
||||||
res = res + formatText(ft[i], preview)
|
res = res + formatText(ft[i], preview, showSecret: showSecrets)
|
||||||
i = i + 1
|
i = i + 1
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -99,7 +103,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let i = icon {
|
if let i = icon {
|
||||||
res = Text(Image(systemName: i)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ") + res
|
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res
|
||||||
}
|
}
|
||||||
|
|
||||||
if let s = sender {
|
if let s = sender {
|
||||||
@@ -110,7 +114,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
|
private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool) -> Text {
|
||||||
let t = ft.text
|
let t = ft.text
|
||||||
if let f = ft.format {
|
if let f = ft.format {
|
||||||
switch (f) {
|
switch (f) {
|
||||||
@@ -118,7 +122,13 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
|
|||||||
case .italic: return Text(t).italic()
|
case .italic: return Text(t).italic()
|
||||||
case .strikeThrough: return Text(t).strikethrough()
|
case .strikeThrough: return Text(t).strikethrough()
|
||||||
case .snippet: return Text(t).font(.body.monospaced())
|
case .snippet: return Text(t).font(.body.monospaced())
|
||||||
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
|
case .secret: return
|
||||||
|
showSecret
|
||||||
|
? Text(t)
|
||||||
|
: Text(AttributedString(t, attributes: AttributeContainer([
|
||||||
|
.foregroundColor: UIColor.clear as Any,
|
||||||
|
.backgroundColor: UIColor.secondarySystemFill as Any
|
||||||
|
])))
|
||||||
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
|
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
|
||||||
case .uri: return linkText(t, t, preview, prefix: "")
|
case .uri: return linkText(t, t, preview, prefix: "")
|
||||||
case let .simplexLink(linkType, simplexUri, smpHosts):
|
case let .simplexLink(linkType, simplexUri, smpHosts):
|
||||||
@@ -144,7 +154,7 @@ private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: Stri
|
|||||||
]))).underline()
|
]))).underline()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
|
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
|
||||||
linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
|
linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +166,8 @@ struct MsgContentView_Previews: PreviewProvider {
|
|||||||
text: chatItem.text,
|
text: chatItem.text,
|
||||||
formattedText: chatItem.formattedText,
|
formattedText: chatItem.formattedText,
|
||||||
sender: chatItem.memberDisplayName,
|
sender: chatItem.memberDisplayName,
|
||||||
meta: chatItem.meta
|
meta: chatItem.meta,
|
||||||
|
showSecrets: false
|
||||||
)
|
)
|
||||||
.environmentObject(Chat.sampleData)
|
.environmentObject(Chat.sampleData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//
|
||||||
|
// ChatItemForwardingView.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by spaced4ndy on 12.04.2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct ChatItemForwardingView: View {
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
var ci: ChatItem
|
||||||
|
var fromChatInfo: ChatInfo
|
||||||
|
@Binding var composeState: ComposeState
|
||||||
|
|
||||||
|
@State private var searchText: String = ""
|
||||||
|
@FocusState private var searchFocused
|
||||||
|
@State private var alert: SomeAlert?
|
||||||
|
@State private var hasSimplexLink_: Bool?
|
||||||
|
private let chatsToForwardTo = filterChatsToForwardTo()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
forwardListView()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
Text("Forward")
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(ThemedBackground())
|
||||||
|
.alert(item: $alert) { $0.alert }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func forwardListView() -> some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if !chatsToForwardTo.isEmpty {
|
||||||
|
List {
|
||||||
|
searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary)
|
||||||
|
.padding(.leading, 2)
|
||||||
|
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||||
|
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
|
||||||
|
ForEach(chats) { chat in
|
||||||
|
forwardListChatView(chat)
|
||||||
|
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func foundChat(_ chat: Chat, _ searchStr: String) -> Bool {
|
||||||
|
let cInfo = chat.chatInfo
|
||||||
|
return switch cInfo {
|
||||||
|
case let .direct(contact):
|
||||||
|
viewNameContains(cInfo, searchStr) ||
|
||||||
|
contact.profile.displayName.localizedLowercase.contains(searchStr) ||
|
||||||
|
contact.fullName.localizedLowercase.contains(searchStr)
|
||||||
|
default:
|
||||||
|
viewNameContains(cInfo, searchStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
|
||||||
|
cInfo.chatViewName.localizedLowercase.contains(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prohibitedByPref(_ chat: Chat) -> Bool {
|
||||||
|
// preference checks should match checks in compose view
|
||||||
|
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||||
|
let fileProhibited = (ci.content.msgContent?.isMediaOrFileAttachment ?? false) && !chat.groupFeatureEnabled(.files)
|
||||||
|
let voiceProhibited = (ci.content.msgContent?.isVoice ?? false) && !chat.chatInfo.featureEnabled(.voice)
|
||||||
|
return switch chat.chatInfo {
|
||||||
|
case .direct: voiceProhibited
|
||||||
|
case .group: simplexLinkProhibited || fileProhibited || voiceProhibited
|
||||||
|
case .local: false
|
||||||
|
case .contactRequest: false
|
||||||
|
case .contactConnection: false
|
||||||
|
case .invalidJSON: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasSimplexLink: Bool {
|
||||||
|
if let hasSimplexLink_ { return hasSimplexLink_ }
|
||||||
|
let r =
|
||||||
|
if let mcText = ci.content.msgContent?.text,
|
||||||
|
let parsedMsg = parseSimpleXMarkdown(mcText) {
|
||||||
|
parsedMsgHasSimplexLink(parsedMsg)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
hasSimplexLink_ = r
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emptyList() -> some View {
|
||||||
|
Text("No filtered chats")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
|
||||||
|
let prohibited = prohibitedByPref(chat)
|
||||||
|
Button {
|
||||||
|
if prohibited {
|
||||||
|
alert = SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Cannot forward message",
|
||||||
|
message: "Selected chat preferences prohibit this message."
|
||||||
|
),
|
||||||
|
id: "forward prohibited by preferences"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
if chat.id == fromChatInfo.id {
|
||||||
|
composeState = ComposeState(
|
||||||
|
message: composeState.message,
|
||||||
|
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
|
||||||
|
contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
|
||||||
|
chatModel.chatId = chat.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
ChatInfoImage(chat: chat, size: 30)
|
||||||
|
.padding(.trailing, 2)
|
||||||
|
Text(chat.chatInfo.chatViewName)
|
||||||
|
.foregroundColor(prohibited ? theme.colors.secondary : theme.colors.onBackground)
|
||||||
|
.lineLimit(1)
|
||||||
|
if chat.chatInfo.incognito {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "theatermasks")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filterChatsToForwardTo() -> [Chat] {
|
||||||
|
var filteredChats = ChatModel.shared.chats.filter { c in
|
||||||
|
c.chatInfo.chatType != .local && canForwardToChat(c)
|
||||||
|
}
|
||||||
|
if let privateNotes = ChatModel.shared.chats.first(where: { $0.chatInfo.chatType == .local }) {
|
||||||
|
filteredChats.insert(privateNotes, at: 0)
|
||||||
|
}
|
||||||
|
return filteredChats
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canForwardToChat(_ chat: Chat) -> Bool {
|
||||||
|
switch chat.chatInfo {
|
||||||
|
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
|
||||||
|
case let .group(groupInfo): groupInfo.sendMsgEnabled
|
||||||
|
case let .local(noteFolder): noteFolder.sendMsgEnabled
|
||||||
|
case .contactRequest: false
|
||||||
|
case .contactConnection: false
|
||||||
|
case .invalidJSON: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ChatItemForwardingView(
|
||||||
|
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
|
||||||
|
fromChatInfo: .direct(contact: Contact.sampleData),
|
||||||
|
composeState: Binding.constant(ComposeState(message: "hello"))
|
||||||
|
).environmentObject(CurrentColors.toAppTheme())
|
||||||
|
}
|
||||||
@@ -11,16 +11,20 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ChatItemInfoView: View {
|
struct ChatItemInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var ci: ChatItem
|
var ci: ChatItem
|
||||||
@Binding var chatItemInfo: ChatItemInfo?
|
@Binding var chatItemInfo: ChatItemInfo?
|
||||||
@State private var selection: CIInfoTab = .history
|
@State private var selection: CIInfoTab = .history
|
||||||
@State private var alert: CIInfoViewAlert? = nil
|
@State private var alert: CIInfoViewAlert? = nil
|
||||||
|
@State private var messageStatusLimited: Bool = true
|
||||||
|
@State private var fileStatusLimited: Bool = true
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
|
|
||||||
enum CIInfoTab {
|
enum CIInfoTab {
|
||||||
case history
|
case history
|
||||||
case quote
|
case quote
|
||||||
|
case forwarded
|
||||||
case delivery
|
case delivery
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +57,9 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var title: String {
|
private var title: String {
|
||||||
ci.chatDir.sent
|
ci.localNote
|
||||||
|
? NSLocalizedString("Saved message", comment: "message info title")
|
||||||
|
: ci.chatDir.sent
|
||||||
? NSLocalizedString("Sent message", comment: "message info title")
|
? NSLocalizedString("Sent message", comment: "message info title")
|
||||||
: NSLocalizedString("Received message", comment: "message info title")
|
: NSLocalizedString("Received message", comment: "message info title")
|
||||||
}
|
}
|
||||||
@@ -66,9 +72,20 @@ struct ChatItemInfoView: View {
|
|||||||
if ci.quotedItem != nil {
|
if ci.quotedItem != nil {
|
||||||
numTabs += 1
|
numTabs += 1
|
||||||
}
|
}
|
||||||
|
if chatItemInfo?.forwardedFromChatItem != nil {
|
||||||
|
numTabs += 1
|
||||||
|
}
|
||||||
return numTabs
|
return numTabs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var local: Bool {
|
||||||
|
switch ci.chatDir {
|
||||||
|
case .localSnd: true
|
||||||
|
case .localRcv: true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder private func itemInfoView() -> some View {
|
@ViewBuilder private func itemInfoView() -> some View {
|
||||||
if numTabs > 1 {
|
if numTabs > 1 {
|
||||||
TabView(selection: $selection) {
|
TabView(selection: $selection) {
|
||||||
@@ -84,12 +101,22 @@ struct ChatItemInfoView: View {
|
|||||||
Label("History", systemImage: "clock")
|
Label("History", systemImage: "clock")
|
||||||
}
|
}
|
||||||
.tag(CIInfoTab.history)
|
.tag(CIInfoTab.history)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
if let qi = ci.quotedItem {
|
if let qi = ci.quotedItem {
|
||||||
quoteTab(qi)
|
quoteTab(qi)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
Label("In reply to", systemImage: "arrowshape.turn.up.left")
|
||||||
}
|
}
|
||||||
.tag(CIInfoTab.quote)
|
.tag(CIInfoTab.quote)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
|
}
|
||||||
|
if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem {
|
||||||
|
forwardedFromTab(forwardedFromItem)
|
||||||
|
.tabItem {
|
||||||
|
Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward")
|
||||||
|
}
|
||||||
|
.tag(CIInfoTab.forwarded)
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -99,6 +126,7 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
historyTab()
|
historyTab()
|
||||||
|
.modifier(ThemedBackground())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +138,11 @@ struct ChatItemInfoView: View {
|
|||||||
.bold()
|
.bold()
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
|
|
||||||
infoRow("Sent at", localTimestamp(meta.itemTs))
|
if ci.localNote {
|
||||||
|
infoRow("Created at", localTimestamp(meta.itemTs))
|
||||||
|
} else {
|
||||||
|
infoRow("Sent at", localTimestamp(meta.itemTs))
|
||||||
|
}
|
||||||
if !ci.chatDir.sent {
|
if !ci.chatDir.sent {
|
||||||
infoRow("Received at", localTimestamp(meta.createdAt))
|
infoRow("Received at", localTimestamp(meta.createdAt))
|
||||||
}
|
}
|
||||||
@@ -131,6 +163,35 @@ struct ChatItemInfoView: View {
|
|||||||
if developerTools {
|
if developerTools {
|
||||||
infoRow("Database ID", "\(meta.itemId)")
|
infoRow("Database ID", "\(meta.itemId)")
|
||||||
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
infoRow("Record updated at", localTimestamp(meta.updatedAt))
|
||||||
|
let msv = infoRow("Message status", ci.meta.itemStatus.id)
|
||||||
|
Group {
|
||||||
|
if messageStatusLimited {
|
||||||
|
msv.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
msv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation {
|
||||||
|
messageStatusLimited.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let file = ci.file {
|
||||||
|
let fsv = infoRow("File status", file.fileStatus.id)
|
||||||
|
Group {
|
||||||
|
if fileStatusLimited {
|
||||||
|
fsv.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
fsv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation {
|
||||||
|
fileStatusLimited.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +216,7 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Text("No history")
|
Text("No history")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,10 +229,9 @@ struct ChatItemInfoView: View {
|
|||||||
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil)
|
||||||
.allowsHitTesting(false)
|
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(ci, colorScheme))
|
.background(chatItemFrameColor(ci, theme))
|
||||||
.cornerRadius(18)
|
.cornerRadius(18)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if itemVersion.msgContent.text != "" {
|
if itemVersion.msgContent.text != "" {
|
||||||
@@ -198,11 +258,23 @@ struct ChatItemInfoView: View {
|
|||||||
|
|
||||||
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
|
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View {
|
||||||
if text != "" {
|
if text != "" {
|
||||||
messageText(text, formattedText, sender)
|
TextBubble(text: text, formattedText: formattedText, sender: sender)
|
||||||
} else {
|
} else {
|
||||||
Text("no text")
|
Text("no text")
|
||||||
.italic()
|
.italic()
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TextBubble: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
var text: String
|
||||||
|
var formattedText: [FormattedText]?
|
||||||
|
var sender: String? = nil
|
||||||
|
@State private var showSecrets = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,10 +299,9 @@ struct ChatItemInfoView: View {
|
|||||||
@ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
|
@ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
textBubble(qi.text, qi.formattedText, qi.getSender(nil))
|
||||||
.allowsHitTesting(false)
|
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(quotedMsgFrameColor(qi, colorScheme))
|
.background(quotedMsgFrameColor(qi, theme))
|
||||||
.cornerRadius(18)
|
.cornerRadius(18)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if qi.text != "" {
|
if qi.text != "" {
|
||||||
@@ -254,10 +325,78 @@ struct ChatItemInfoView: View {
|
|||||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
func quotedMsgFrameColor(_ qi: CIQuote, _ colorScheme: ColorScheme) -> Color {
|
func quotedMsgFrameColor(_ qi: CIQuote, _ theme: AppTheme) -> Color {
|
||||||
(qi.chatDir?.sent ?? false)
|
(qi.chatDir?.sent ?? false)
|
||||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
? theme.appColors.sentMessage
|
||||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
: theme.appColors.receivedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
details()
|
||||||
|
Divider().padding(.vertical)
|
||||||
|
Text(local ? "Saved from" : "Forwarded from")
|
||||||
|
.font(.title2)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
forwardedFromView(forwardedFromItem)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func forwardedFromView(_ forwardedFromItem: AChatItem) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await MainActor.run {
|
||||||
|
chatModel.chatId = forwardedFromItem.chatInfo.id
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
forwardedFromSender(forwardedFromItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !local {
|
||||||
|
Divider().padding(.top, 32)
|
||||||
|
Text("Recipient(s) can't see who this message is from.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
|
||||||
|
HStack {
|
||||||
|
ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48)
|
||||||
|
.padding(.trailing, 6)
|
||||||
|
|
||||||
|
if forwardedFromItem.chatItem.chatDir.sent {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("you")
|
||||||
|
.italic()
|
||||||
|
.foregroundColor(theme.colors.onBackground)
|
||||||
|
Text(forwardedFromItem.chatInfo.chatViewName)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(groupMember.chatViewName)
|
||||||
|
.foregroundColor(theme.colors.onBackground)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(forwardedFromItem.chatInfo.chatViewName)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(forwardedFromItem.chatInfo.chatViewName)
|
||||||
|
.foregroundColor(theme.colors.onBackground)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
@ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||||
@@ -280,35 +419,38 @@ struct ChatItemInfoView: View {
|
|||||||
let mss = membersStatuses(memberDeliveryStatuses)
|
let mss = membersStatuses(memberDeliveryStatuses)
|
||||||
if !mss.isEmpty {
|
if !mss.isEmpty {
|
||||||
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
||||||
memberDeliveryStatusView(memberStatus.0, memberStatus.1)
|
memberDeliveryStatusView(memberStatus.0, memberStatus.1, memberStatus.2)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No delivery information")
|
Text("No delivery information")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
|
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, Bool?)] {
|
||||||
memberDeliveryStatuses.compactMap({ mds in
|
memberDeliveryStatuses.compactMap({ mds in
|
||||||
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
||||||
return (mem.wrapped, mds.memberDeliveryStatus)
|
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View {
|
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View {
|
||||||
HStack{
|
HStack{
|
||||||
ProfileImage(imageStr: member.image)
|
ProfileImage(imageStr: member.image, size: 30)
|
||||||
.frame(width: 30, height: 30)
|
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
Text(member.chatViewName)
|
Text(member.chatViewName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if sentViaProxy == true {
|
||||||
|
Image(systemName: "arrow.forward")
|
||||||
|
.foregroundColor(theme.colors.secondary).opacity(0.67)
|
||||||
|
}
|
||||||
let v = Group {
|
let v = Group {
|
||||||
if let (icon, statusColor) = status.statusIcon(Color.secondary) {
|
if let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) {
|
||||||
switch status {
|
switch status {
|
||||||
case .sndRcvd:
|
case .sndRcvd:
|
||||||
ZStack(alignment: .trailing) {
|
ZStack(alignment: .trailing) {
|
||||||
@@ -341,7 +483,12 @@ struct ChatItemInfoView: View {
|
|||||||
private func itemInfoShareText() -> String {
|
private func itemInfoShareText() -> String {
|
||||||
let meta = ci.meta
|
let meta = ci.meta
|
||||||
var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""]
|
var shareText: [String] = [String.localizedStringWithFormat(NSLocalizedString("# %@", comment: "copied message info title, # <title>"), title), ""]
|
||||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Sent at: %@", comment: "copied message info"), localTimestamp(meta.itemTs))]
|
shareText += [String.localizedStringWithFormat(
|
||||||
|
ci.localNote
|
||||||
|
? NSLocalizedString("Created at: %@", comment: "copied message info")
|
||||||
|
: NSLocalizedString("Sent at: %@", comment: "copied message info"),
|
||||||
|
localTimestamp(meta.itemTs))
|
||||||
|
]
|
||||||
if !ci.chatDir.sent {
|
if !ci.chatDir.sent {
|
||||||
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
|
shareText += [String.localizedStringWithFormat(NSLocalizedString("Received at: %@", comment: "copied message info"), localTimestamp(meta.createdAt))]
|
||||||
}
|
}
|
||||||
@@ -362,8 +509,12 @@ struct ChatItemInfoView: View {
|
|||||||
if developerTools {
|
if developerTools {
|
||||||
shareText += [
|
shareText += [
|
||||||
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
|
String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId),
|
||||||
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt))
|
String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt)),
|
||||||
|
String.localizedStringWithFormat(NSLocalizedString("Message status: %@", comment: "copied message info"), meta.itemStatus.id)
|
||||||
]
|
]
|
||||||
|
if let file = ci.file {
|
||||||
|
shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let qi = ci.quotedItem {
|
if let qi = ci.quotedItem {
|
||||||
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
|
shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")]
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ChatItemView: View {
|
struct ChatItemView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
var maxWidth: CGFloat = .infinity
|
var maxWidth: CGFloat = .infinity
|
||||||
@State var scrollProxy: ScrollViewProxy? = nil
|
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
@Binding var audioPlayer: AudioPlayer?
|
@Binding var audioPlayer: AudioPlayer?
|
||||||
@@ -24,7 +24,6 @@ struct ChatItemView: View {
|
|||||||
chatItem: ChatItem,
|
chatItem: ChatItem,
|
||||||
showMember: Bool = false,
|
showMember: Bool = false,
|
||||||
maxWidth: CGFloat = .infinity,
|
maxWidth: CGFloat = .infinity,
|
||||||
scrollProxy: ScrollViewProxy? = nil,
|
|
||||||
revealed: Binding<Bool>,
|
revealed: Binding<Bool>,
|
||||||
allowMenu: Binding<Bool> = .constant(false),
|
allowMenu: Binding<Bool> = .constant(false),
|
||||||
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
|
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
|
||||||
@@ -34,7 +33,6 @@ struct ChatItemView: View {
|
|||||||
self.chat = chat
|
self.chat = chat
|
||||||
self.chatItem = chatItem
|
self.chatItem = chatItem
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
_scrollProxy = .init(initialValue: scrollProxy)
|
|
||||||
_revealed = revealed
|
_revealed = revealed
|
||||||
_allowMenu = allowMenu
|
_allowMenu = allowMenu
|
||||||
_audioPlayer = audioPlayer
|
_audioPlayer = audioPlayer
|
||||||
@@ -46,7 +44,7 @@ struct ChatItemView: View {
|
|||||||
let ci = chatItem
|
let ci = chatItem
|
||||||
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
|
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
|
||||||
MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
|
MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
|
||||||
} else if ci.quotedItem == 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 {
|
||||||
@@ -62,12 +60,43 @@ struct ChatItemView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func framedItemView() -> some View {
|
private func framedItemView() -> some View {
|
||||||
FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
let preview = chatItem.content.msgContent
|
||||||
|
.flatMap {
|
||||||
|
switch $0 {
|
||||||
|
case let .image(_, image): image
|
||||||
|
case let .video(_, image, _): image
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map { dropImagePrefix($0) }
|
||||||
|
.flatMap { Data(base64Encoded: $0) }
|
||||||
|
.flatMap { UIImage(data: $0) }
|
||||||
|
let adjustedMaxWidth = {
|
||||||
|
if let preview, preview.size.width <= preview.size.height {
|
||||||
|
maxWidth * 0.75
|
||||||
|
} else {
|
||||||
|
maxWidth
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return FramedItemView(
|
||||||
|
chat: chat,
|
||||||
|
chatItem: chatItem,
|
||||||
|
preview: preview,
|
||||||
|
revealed: $revealed,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
imgWidth: adjustedMaxWidth,
|
||||||
|
videoWidth: adjustedMaxWidth,
|
||||||
|
allowMenu: $allowMenu,
|
||||||
|
audioPlayer: $audioPlayer,
|
||||||
|
playbackState: $playbackState,
|
||||||
|
playbackTime: $playbackTime
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChatItemContentView<Content: View>: View {
|
struct ChatItemContentView<Content: View>: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
@@ -97,18 +126,23 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
case .sndGroupEvent: eventItemView()
|
case .sndGroupEvent: eventItemView()
|
||||||
case .rcvConnEvent: eventItemView()
|
case .rcvConnEvent: eventItemView()
|
||||||
case .sndConnEvent: eventItemView()
|
case .sndConnEvent: eventItemView()
|
||||||
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary))
|
||||||
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor(theme.colors.secondary))
|
||||||
case let .rcvChatPreference(feature, allowed, param):
|
case let .rcvChatPreference(feature, allowed, param):
|
||||||
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
||||||
case let .sndChatPreference(feature, _, _):
|
case let .sndChatPreference(feature, _, _):
|
||||||
CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
|
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
|
||||||
case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
|
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
|
||||||
case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
|
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
|
||||||
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||||
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||||
case .sndModerated: deletedItemView()
|
case .sndModerated: deletedItemView()
|
||||||
case .rcvModerated: deletedItemView()
|
case .rcvModerated: deletedItemView()
|
||||||
|
case .rcvBlocked: deletedItemView()
|
||||||
|
case let .sndDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo))
|
||||||
|
case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo))
|
||||||
|
case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
|
||||||
|
case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText())
|
||||||
case let .invalidJSON(json): CIInvalidJSONView(json: json)
|
case let .invalidJSON(json): CIInvalidJSONView(json: json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,29 +156,29 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
|
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
|
||||||
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito)
|
CIGroupInvitationView(chat: chat, chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func eventItemView() -> some View {
|
private func eventItemView() -> some View {
|
||||||
return CIEventView(eventText: eventItemViewText())
|
CIEventView(eventText: eventItemViewText(theme.colors.secondary))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func eventItemViewText() -> Text {
|
private func eventItemViewText(_ secondaryColor: Color) -> Text {
|
||||||
if !revealed, let t = mergedGroupEventText {
|
if !revealed, let t = mergedGroupEventText {
|
||||||
return chatEventText(t + Text(" ") + chatItem.timestampText)
|
return chatEventText(t + Text(" ") + chatItem.timestampText, secondaryColor)
|
||||||
} else if let member = chatItem.memberDisplayName {
|
} else if let member = chatItem.memberDisplayName {
|
||||||
return Text(member + " ")
|
return Text(member + " ")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
+ chatEventText(chatItem)
|
+ chatEventText(chatItem, secondaryColor)
|
||||||
} else {
|
} else {
|
||||||
return chatEventText(chatItem)
|
return chatEventText(chatItem, secondaryColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
|
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
|
||||||
CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
|
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mergedGroupEventText: Text? {
|
private var mergedGroupEventText: Text? {
|
||||||
@@ -169,21 +203,37 @@ struct ChatItemContentView<Content: View>: View {
|
|||||||
Text(members)
|
Text(members)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func directE2EEInfoText(_ info: E2EEInfo) -> Text {
|
||||||
|
info.pqEnabled
|
||||||
|
? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.fontWeight(.light)
|
||||||
|
: e2eeInfoNoPQText()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func e2eeInfoNoPQText() -> Text {
|
||||||
|
Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.fontWeight(.light)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatEventText(_ text: Text) -> Text {
|
func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text {
|
||||||
text
|
text
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.fontWeight(.light)
|
.fontWeight(.light)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
|
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text {
|
||||||
chatEventText(Text(eventText) + Text(" ") + ts)
|
chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatEventText(_ ci: ChatItem) -> Text {
|
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
|
||||||
chatEventText("\(ci.content.text)", ci.timestampText)
|
chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChatItemView_Previews: PreviewProvider {
|
struct ChatItemView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ComposeFileView: View {
|
struct ComposeFileView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let fileName: String
|
let fileName: String
|
||||||
let cancelFile: (() -> Void)
|
let cancelFile: (() -> Void)
|
||||||
let cancelEnabled: Bool
|
let cancelEnabled: Bool
|
||||||
@@ -33,7 +33,7 @@ struct ComposeFileView: View {
|
|||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct ComposeImageView: View {
|
struct ComposeImageView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let images: [String]
|
let images: [String]
|
||||||
let cancelImage: (() -> Void)
|
let cancelImage: (() -> Void)
|
||||||
let cancelEnabled: Bool
|
let cancelEnabled: Bool
|
||||||
@@ -48,7 +48,7 @@ struct ComposeImageView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ComposeLinkView: View {
|
struct ComposeLinkView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let linkPreview: LinkPreview?
|
let linkPreview: LinkPreview?
|
||||||
var cancelPreview: (() -> Void)? = nil
|
var cancelPreview: (() -> Void)? = nil
|
||||||
let cancelEnabled: Bool
|
let cancelEnabled: Bool
|
||||||
@@ -62,7 +62,7 @@ struct ComposeLinkView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ struct ComposeLinkView: View {
|
|||||||
Text(linkPreview.uri.absoluteString)
|
Text(linkPreview.uri.absoluteString)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
.frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
|
.frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ enum ComposeContextItem {
|
|||||||
case noContextItem
|
case noContextItem
|
||||||
case quotedItem(chatItem: ChatItem)
|
case quotedItem(chatItem: ChatItem)
|
||||||
case editingItem(chatItem: ChatItem)
|
case editingItem(chatItem: ChatItem)
|
||||||
|
case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VoiceMessageRecordingState {
|
enum VoiceMessageRecordingState {
|
||||||
@@ -72,6 +73,13 @@ struct ComposeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) {
|
||||||
|
self.message = ""
|
||||||
|
self.preview = .noPreview
|
||||||
|
self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo)
|
||||||
|
self.voiceMessageRecordingState = .noRecording
|
||||||
|
}
|
||||||
|
|
||||||
func copy(
|
func copy(
|
||||||
message: String? = nil,
|
message: String? = nil,
|
||||||
liveMessage: LiveMessage? = nil,
|
liveMessage: LiveMessage? = nil,
|
||||||
@@ -102,12 +110,19 @@ struct ComposeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var forwarding: Bool {
|
||||||
|
switch contextItem {
|
||||||
|
case .forwardingItem: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var sendEnabled: Bool {
|
var sendEnabled: Bool {
|
||||||
switch preview {
|
switch preview {
|
||||||
case .mediaPreviews: return true
|
case let .mediaPreviews(media): return !media.isEmpty
|
||||||
case .voicePreview: return voiceMessageRecordingState == .finished
|
case .voicePreview: return voiceMessageRecordingState == .finished
|
||||||
case .filePreview: return true
|
case .filePreview: return true
|
||||||
default: return !message.isEmpty || liveMessage != nil
|
default: return !message.isEmpty || forwarding || liveMessage != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +168,7 @@ struct ComposeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var attachmentDisabled: Bool {
|
var attachmentDisabled: Bool {
|
||||||
if editing || liveMessage != nil || inProgress { return true }
|
if editing || forwarding || liveMessage != nil || inProgress { return true }
|
||||||
switch preview {
|
switch preview {
|
||||||
case .noPreview: return false
|
case .noPreview: return false
|
||||||
case .linkPreview: return false
|
case .linkPreview: return false
|
||||||
@@ -161,6 +176,16 @@ struct ComposeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var attachmentPreview: Bool {
|
||||||
|
switch preview {
|
||||||
|
case .noPreview: false
|
||||||
|
case .linkPreview: false
|
||||||
|
case let .mediaPreviews(mediaPreviews): !mediaPreviews.isEmpty
|
||||||
|
case .voicePreview: false
|
||||||
|
case .filePreview: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var empty: Bool {
|
var empty: Bool {
|
||||||
message == "" && noPreview
|
message == "" && noPreview
|
||||||
}
|
}
|
||||||
@@ -229,11 +254,13 @@ enum UploadContent: Equatable {
|
|||||||
|
|
||||||
struct ComposeView: View {
|
struct ComposeView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
@Binding var keyboardVisible: Bool
|
@Binding var keyboardVisible: Bool
|
||||||
|
|
||||||
@State var linkUrl: URL? = nil
|
@State var linkUrl: URL? = nil
|
||||||
|
@State var hasSimplexLink: Bool = false
|
||||||
@State var prevLinkUrl: URL? = nil
|
@State var prevLinkUrl: URL? = nil
|
||||||
@State var pendingLinkUrl: URL? = nil
|
@State var pendingLinkUrl: URL? = nil
|
||||||
@State var cancelledLinks: Set<String> = []
|
@State var cancelledLinks: Set<String> = []
|
||||||
@@ -260,6 +287,17 @@ struct ComposeView: View {
|
|||||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||||
ContextInvitingContactMemberView()
|
ContextInvitingContactMemberView()
|
||||||
}
|
}
|
||||||
|
// preference checks should match checks in forwarding list
|
||||||
|
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||||
|
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
|
||||||
|
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
|
||||||
|
if simplexLinkProhibited {
|
||||||
|
msgNotAllowedView("SimpleX links not allowed", icon: "link")
|
||||||
|
} else if fileProhibited {
|
||||||
|
msgNotAllowedView("Files and media not allowed", icon: "doc")
|
||||||
|
} else if voiceProhibited {
|
||||||
|
msgNotAllowedView("Voice messages not allowed", icon: "mic")
|
||||||
|
}
|
||||||
contextItemView()
|
contextItemView()
|
||||||
switch (composeState.editing, composeState.preview) {
|
switch (composeState.editing, composeState.preview) {
|
||||||
case (true, .filePreview): EmptyView()
|
case (true, .filePreview): EmptyView()
|
||||||
@@ -277,8 +315,9 @@ struct ComposeView: View {
|
|||||||
.frame(width: 25, height: 25)
|
.frame(width: 25, height: 25)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
|
.tint(theme.colors.primary)
|
||||||
if case let .group(g) = chat.chatInfo,
|
if case let .group(g) = chat.chatInfo,
|
||||||
!g.fullGroupPreferences.files.on {
|
!g.fullGroupPreferences.files.on(for: g.membership) {
|
||||||
b.disabled(true).onTapGesture {
|
b.disabled(true).onTapGesture {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title: "Files and media prohibited!",
|
title: "Files and media prohibited!",
|
||||||
@@ -295,7 +334,7 @@ struct ComposeView: View {
|
|||||||
sendMessage(ttl: ttl)
|
sendMessage(ttl: ttl)
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
},
|
},
|
||||||
sendLiveMessage: sendLiveMessage,
|
sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil,
|
||||||
updateLiveMessage: updateLiveMessage,
|
updateLiveMessage: updateLiveMessage,
|
||||||
cancelLiveMessage: {
|
cancelLiveMessage: {
|
||||||
composeState.liveMessage = nil
|
composeState.liveMessage = nil
|
||||||
@@ -303,6 +342,7 @@ struct ComposeView: View {
|
|||||||
},
|
},
|
||||||
nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
|
nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false,
|
||||||
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
|
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
|
||||||
|
disableSendButton: simplexLinkProhibited || fileProhibited || voiceProhibited,
|
||||||
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
||||||
startVoiceMessageRecording: {
|
startVoiceMessageRecording: {
|
||||||
Task {
|
Task {
|
||||||
@@ -316,16 +356,16 @@ struct ComposeView: View {
|
|||||||
keyboardVisible: $keyboardVisible,
|
keyboardVisible: $keyboardVisible,
|
||||||
sendButtonColor: chat.chatInfo.incognito
|
sendButtonColor: chat.chatInfo.incognito
|
||||||
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
||||||
: .accentColor
|
: theme.colors.primary
|
||||||
)
|
)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(.background)
|
.background(theme.colors.background)
|
||||||
.disabled(!chat.userCanSend)
|
.disabled(!chat.userCanSend)
|
||||||
|
|
||||||
if chat.userIsObserver {
|
if chat.userIsObserver {
|
||||||
Text("you are observer")
|
Text("you are observer")
|
||||||
.italic()
|
.italic()
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
@@ -337,13 +377,18 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: composeState.message) { _ in
|
.onChange(of: composeState.message) { msg in
|
||||||
if composeState.linkPreviewAllowed {
|
if composeState.linkPreviewAllowed {
|
||||||
if composeState.message.count > 0 {
|
if msg.count > 0 {
|
||||||
showLinkPreview(composeState.message)
|
showLinkPreview(msg)
|
||||||
} else {
|
} else {
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
|
hasSimplexLink = false
|
||||||
}
|
}
|
||||||
|
} else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
|
||||||
|
(_, hasSimplexLink) = parseMessage(msg)
|
||||||
|
} else {
|
||||||
|
hasSimplexLink = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: chat.userCanSend) { canSend in
|
.onChange(of: chat.userCanSend) { canSend in
|
||||||
@@ -384,10 +429,10 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showMediaPicker) {
|
.sheet(isPresented: $showMediaPicker) {
|
||||||
LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in
|
LibraryMediaListPicker(addMedia: addMediaContent, selectionLimit: 10, finishedPreprocessing: finishedPreprocessingMediaContent) { itemsSelected in
|
||||||
showMediaPicker = false
|
await MainActor.run {
|
||||||
if itemsSelected {
|
showMediaPicker = false
|
||||||
DispatchQueue.main.async {
|
if itemsSelected {
|
||||||
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
|
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,6 +533,30 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func addMediaContent(_ content: UploadContent) async {
|
||||||
|
if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||||
|
var newMedia: [(String, UploadContent?)] = []
|
||||||
|
if case var .mediaPreviews(media) = composeState.preview {
|
||||||
|
media.append((img, content))
|
||||||
|
newMedia = media
|
||||||
|
} else {
|
||||||
|
newMedia = [(img, content)]
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: newMedia))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When error occurs while converting video, remove media preview
|
||||||
|
private func finishedPreprocessingMediaContent() {
|
||||||
|
if case let .mediaPreviews(media) = composeState.preview, media.isEmpty {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
composeState = composeState.copy(preview: .noPreview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var maxFileSize: Int64 {
|
private var maxFileSize: Int64 {
|
||||||
getMaxFileSize(.xftp)
|
getMaxFileSize(.xftp)
|
||||||
}
|
}
|
||||||
@@ -586,6 +655,18 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon).foregroundColor(theme.colors.secondary)
|
||||||
|
Text(reason).italic()
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(minHeight: 50)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder private func contextItemView() -> some View {
|
@ViewBuilder private func contextItemView() -> some View {
|
||||||
switch composeState.contextItem {
|
switch composeState.contextItem {
|
||||||
case .noContextItem:
|
case .noContextItem:
|
||||||
@@ -604,6 +685,14 @@ struct ComposeView: View {
|
|||||||
contextIcon: "pencil",
|
contextIcon: "pencil",
|
||||||
cancelContextItem: { clearState() }
|
cancelContextItem: { clearState() }
|
||||||
)
|
)
|
||||||
|
case let .forwardingItem(chatItem: forwardedItem, _):
|
||||||
|
ContextItemView(
|
||||||
|
chat: chat,
|
||||||
|
contextItem: forwardedItem,
|
||||||
|
contextIcon: "arrowshape.turn.up.forward",
|
||||||
|
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
|
||||||
|
showSender: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +714,11 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||||
await sendMemberContactInvitation()
|
await sendMemberContactInvitation()
|
||||||
|
} else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem {
|
||||||
|
sent = await forwardItem(ci, fromChatInfo, ttl)
|
||||||
|
if !composeState.message.isEmpty {
|
||||||
|
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)
|
||||||
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
|
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
|
||||||
@@ -665,12 +759,20 @@ struct ComposeView: View {
|
|||||||
let file = voiceCryptoFile(recordingFileName)
|
let file = voiceCryptoFile(recordingFileName)
|
||||||
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
|
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl)
|
||||||
case let .filePreview(_, file):
|
case let .filePreview(_, file):
|
||||||
if let savedFile = saveFileFromURL(file, encrypted: privacyEncryptLocalFilesGroupDefault.get()) {
|
if let savedFile = saveFileFromURL(file) {
|
||||||
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await MainActor.run { clearState(live: live) }
|
await MainActor.run {
|
||||||
|
let wasForwarding = composeState.forwarding
|
||||||
|
clearState(live: live)
|
||||||
|
if wasForwarding,
|
||||||
|
chatModel.draftChatId == chat.chatInfo.id,
|
||||||
|
let draft = chatModel.draft {
|
||||||
|
composeState = draft
|
||||||
|
}
|
||||||
|
}
|
||||||
return sent
|
return sent
|
||||||
|
|
||||||
func sending() async {
|
func sending() async {
|
||||||
@@ -768,15 +870,17 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||||
if let chatItem = await apiSendMessage(
|
if let chatItem = chat.chatInfo.chatType == .local
|
||||||
type: chat.chatInfo.chatType,
|
? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc)
|
||||||
id: chat.chatInfo.apiId,
|
: await apiSendMessage(
|
||||||
file: file,
|
type: chat.chatInfo.chatType,
|
||||||
quotedItemId: quoted,
|
id: chat.chatInfo.apiId,
|
||||||
msg: mc,
|
file: file,
|
||||||
live: live,
|
quotedItemId: quoted,
|
||||||
ttl: ttl
|
msg: mc,
|
||||||
) {
|
live: live,
|
||||||
|
ttl: ttl
|
||||||
|
) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
chatModel.removeLiveDummy(animated: false)
|
chatModel.removeLiveDummy(animated: false)
|
||||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||||
@@ -789,10 +893,27 @@ struct ComposeView: View {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
|
||||||
|
if let chatItem = await apiForwardChatItem(
|
||||||
|
toChatType: chat.chatInfo.chatType,
|
||||||
|
toChatId: chat.chatInfo.apiId,
|
||||||
|
fromChatType: fromChatInfo.chatType,
|
||||||
|
fromChatId: fromChatInfo.apiId,
|
||||||
|
itemId: forwardedItem.id,
|
||||||
|
ttl: ttl
|
||||||
|
) {
|
||||||
|
await MainActor.run {
|
||||||
|
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||||
|
}
|
||||||
|
return chatItem
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func checkLinkPreview() -> MsgContent {
|
func checkLinkPreview() -> MsgContent {
|
||||||
switch (composeState.preview) {
|
switch (composeState.preview) {
|
||||||
case let .linkPreview(linkPreview: linkPreview):
|
case let .linkPreview(linkPreview: linkPreview):
|
||||||
if let url = parseMessage(msgText),
|
if let url = parseMessage(msgText).url,
|
||||||
let linkPreview = linkPreview,
|
let linkPreview = linkPreview,
|
||||||
url == linkPreview.uri {
|
url == linkPreview.uri {
|
||||||
return .link(text: msgText, preview: linkPreview)
|
return .link(text: msgText, preview: linkPreview)
|
||||||
@@ -921,7 +1042,7 @@ struct ComposeView: View {
|
|||||||
|
|
||||||
private func showLinkPreview(_ s: String) {
|
private func showLinkPreview(_ s: String) {
|
||||||
prevLinkUrl = linkUrl
|
prevLinkUrl = linkUrl
|
||||||
linkUrl = parseMessage(s)
|
(linkUrl, hasSimplexLink) = parseMessage(s)
|
||||||
if let url = linkUrl {
|
if let url = linkUrl {
|
||||||
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
|
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
|
||||||
pendingLinkUrl = url
|
pendingLinkUrl = url
|
||||||
@@ -938,13 +1059,17 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parseMessage(_ msg: String) -> URL? {
|
private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) {
|
||||||
let parsedMsg = parseSimpleXMarkdown(msg)
|
guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) }
|
||||||
let uri = parsedMsg?.first(where: { ft in
|
let url: URL? = if let uri = parsedMsg.first(where: { ft in
|
||||||
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
|
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
|
||||||
})
|
}) {
|
||||||
if let uri = uri { return URL(string: uri.text) }
|
URL(string: uri.text)
|
||||||
else { return nil }
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
|
||||||
|
return (url, simplexLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isSimplexLink(_ link: String) -> Bool {
|
private func isSimplexLink(_ link: String) -> Bool {
|
||||||
@@ -952,6 +1077,9 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func cancelLinkPreview() {
|
private func cancelLinkPreview() {
|
||||||
|
if let pendingLink = pendingLinkUrl?.absoluteString {
|
||||||
|
cancelledLinks.insert(pendingLink)
|
||||||
|
}
|
||||||
if let uri = composeState.linkPreview?.uri.absoluteString {
|
if let uri = composeState.linkPreview?.uri.absoluteString {
|
||||||
cancelledLinks.insert(uri)
|
cancelledLinks.insert(uri)
|
||||||
}
|
}
|
||||||
@@ -980,6 +1108,10 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool {
|
||||||
|
parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
|
||||||
|
}
|
||||||
|
|
||||||
struct ComposeView_Previews: PreviewProvider {
|
struct ComposeView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func voiceMessageTime_(_ time: TimeInterval?) -> String {
|
|||||||
|
|
||||||
struct ComposeVoiceView: View {
|
struct ComposeVoiceView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var recordingFileName: String
|
var recordingFileName: String
|
||||||
@Binding var recordingTime: TimeInterval?
|
@Binding var recordingTime: TimeInterval?
|
||||||
@Binding var recordingState: VoiceMessageRecordingState
|
@Binding var recordingState: VoiceMessageRecordingState
|
||||||
@@ -50,7 +50,7 @@ struct ComposeVoiceView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.frame(height: ComposeVoiceView.previewHeight)
|
.frame(height: ComposeVoiceView.previewHeight)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ struct ComposeVoiceView: View {
|
|||||||
Button {
|
Button {
|
||||||
startPlayback()
|
startPlayback()
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
Text(voiceMessageTime_(recordingTime))
|
Text(voiceMessageTime_(recordingTime))
|
||||||
case .playing:
|
case .playing:
|
||||||
@@ -88,7 +88,7 @@ struct ComposeVoiceView: View {
|
|||||||
audioPlayer?.pause()
|
audioPlayer?.pause()
|
||||||
playbackState = .paused
|
playbackState = .paused
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("pause.fill")
|
playPauseIcon("pause.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
Text(voiceMessageTime_(playbackTime))
|
Text(voiceMessageTime_(playbackTime))
|
||||||
case .paused:
|
case .paused:
|
||||||
@@ -96,7 +96,7 @@ struct ComposeVoiceView: View {
|
|||||||
audioPlayer?.play()
|
audioPlayer?.play()
|
||||||
playbackState = .playing
|
playbackState = .playing
|
||||||
} label: {
|
} label: {
|
||||||
playPauseIcon("play.fill")
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
}
|
||||||
Text(voiceMessageTime_(playbackTime))
|
Text(voiceMessageTime_(playbackTime))
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ struct ComposeVoiceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playPauseIcon(_ image: String, _ color: Color = .accentColor) -> some View {
|
private func playPauseIcon(_ image: String, _ color: Color) -> some View {
|
||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
@@ -147,9 +147,11 @@ struct ComposeVoiceView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "multiply")
|
Image(systemName: "multiply")
|
||||||
}
|
}
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SliderBar: View {
|
struct SliderBar: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var length: TimeInterval
|
var length: TimeInterval
|
||||||
@Binding var progress: TimeInterval?
|
@Binding var progress: TimeInterval?
|
||||||
var seek: (TimeInterval) -> Void
|
var seek: (TimeInterval) -> Void
|
||||||
@@ -158,10 +160,12 @@ struct ComposeVoiceView: View {
|
|||||||
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
|
Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 4)
|
.frame(height: 4)
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ProgressBar: View {
|
private struct ProgressBar: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var length: TimeInterval
|
var length: TimeInterval
|
||||||
@Binding var progress: TimeInterval?
|
@Binding var progress: TimeInterval?
|
||||||
|
|
||||||
@@ -169,7 +173,7 @@ struct ComposeVoiceView: View {
|
|||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack {
|
ZStack {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.accentColor)
|
.fill(theme.colors.primary)
|
||||||
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
|
.frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4)
|
||||||
.animation(.linear, value: progress)
|
.animation(.linear, value: progress)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContextInvitingContactMemberView: View {
|
struct ContextInvitingContactMemberView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "message")
|
Image(systemName: "message")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
Text("Send direct message to connect")
|
Text("Send direct message to connect")
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 50)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
.background(theme.appColors.sentMessage)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct ContextItemView: View {
|
struct ContextItemView: View {
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
let contextItem: ChatItem
|
let contextItem: ChatItem
|
||||||
let contextIcon: String
|
let contextIcon: String
|
||||||
let cancelContextItem: () -> Void
|
let cancelContextItem: () -> Void
|
||||||
|
var showSender: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -22,10 +23,10 @@ struct ContextItemView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
if let sender = contextItem.memberDisplayName {
|
if showSender, let sender = contextItem.memberDisplayName {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(sender).font(.caption).foregroundColor(.secondary)
|
Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
|
||||||
msgContentView(lines: 2)
|
msgContentView(lines: 2)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -39,22 +40,36 @@ struct ContextItemView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "multiply")
|
Image(systemName: "multiply")
|
||||||
}
|
}
|
||||||
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 50)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(chatItemFrameColor(contextItem, colorScheme))
|
.background(chatItemFrameColor(contextItem, theme))
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func msgContentView(lines: Int) -> some View {
|
private func msgContentView(lines: Int) -> some View {
|
||||||
MsgContentView(
|
contextMsgPreview()
|
||||||
chat: chat,
|
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
|
||||||
text: contextItem.text,
|
.lineLimit(lines)
|
||||||
formattedText: contextItem.formattedText
|
}
|
||||||
)
|
|
||||||
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
|
private func contextMsgPreview() -> Text {
|
||||||
.lineLimit(lines)
|
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||||
|
|
||||||
|
func attachment() -> Text {
|
||||||
|
switch contextItem.content.msgContent {
|
||||||
|
case .file: return image("doc.fill")
|
||||||
|
case .image: return image("photo")
|
||||||
|
case .voice: return image("play.fill")
|
||||||
|
default: return Text("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func image(_ s: String) -> Text {
|
||||||
|
Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||||||
@Binding var disableEditing: Bool
|
@Binding var disableEditing: Bool
|
||||||
@Binding var height: CGFloat
|
@Binding var height: CGFloat
|
||||||
@Binding var focused: Bool
|
@Binding var focused: Bool
|
||||||
let alignment: TextAlignment
|
|
||||||
let onImagesAdded: ([UploadContent]) -> Void
|
let onImagesAdded: ([UploadContent]) -> Void
|
||||||
|
|
||||||
private let minHeight: CGFloat = 37
|
private let minHeight: CGFloat = 37
|
||||||
@@ -29,14 +28,18 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let field = CustomUITextField(height: _height)
|
let field = CustomUITextField(height: _height)
|
||||||
|
field.backgroundColor = .clear
|
||||||
field.text = text
|
field.text = text
|
||||||
field.textAlignment = alignment == .leading ? .left : .right
|
field.textAlignment = alignment(text)
|
||||||
field.autocapitalizationType = .sentences
|
field.autocapitalizationType = .sentences
|
||||||
field.setOnTextChangedListener { newText, images in
|
field.setOnTextChangedListener { newText, images in
|
||||||
if !disableEditing {
|
if !disableEditing {
|
||||||
// Speed up the process of updating layout, reduce jumping content on screen
|
|
||||||
if !isShortEmoji(newText) { updateHeight(field) }
|
|
||||||
text = newText
|
text = newText
|
||||||
|
field.textAlignment = alignment(text)
|
||||||
|
updateFont(field)
|
||||||
|
// Speed up the process of updating layout, reduce jumping content on screen
|
||||||
|
updateHeight(field)
|
||||||
|
self.height = field.frame.size.height
|
||||||
} else {
|
} else {
|
||||||
field.text = text
|
field.text = text
|
||||||
}
|
}
|
||||||
@@ -53,10 +56,12 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ field: UITextView, context: Context) {
|
func updateUIView(_ field: UITextView, context: Context) {
|
||||||
field.text = text
|
if field.markedTextRange == nil && field.text != text {
|
||||||
field.textAlignment = alignment == .leading ? .left : .right
|
field.text = text
|
||||||
updateFont(field)
|
field.textAlignment = alignment(text)
|
||||||
updateHeight(field)
|
updateFont(field)
|
||||||
|
updateHeight(field)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateHeight(_ field: UITextView) {
|
private func updateHeight(_ field: UITextView) {
|
||||||
@@ -73,12 +78,19 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateFont(_ field: UITextView) {
|
private func updateFont(_ field: UITextView) {
|
||||||
field.font = isShortEmoji(field.text)
|
let newFont = isShortEmoji(field.text)
|
||||||
? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
|
? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
|
||||||
: UIFont.preferredFont(forTextStyle: .body)
|
: UIFont.preferredFont(forTextStyle: .body)
|
||||||
|
if field.font != newFont {
|
||||||
|
field.font = newFont
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func alignment(_ text: String) -> NSTextAlignment {
|
||||||
|
isRightToLeft(text) ? .right : .left
|
||||||
|
}
|
||||||
|
|
||||||
private class CustomUITextField: UITextView, UITextViewDelegate {
|
private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||||
var height: Binding<CGFloat>
|
var height: Binding<CGFloat>
|
||||||
var newHeight: CGFloat = 0
|
var newHeight: CGFloat = 0
|
||||||
@@ -205,7 +217,6 @@ struct NativeTextEditor_Previews: PreviewProvider{
|
|||||||
disableEditing: Binding.constant(false),
|
disableEditing: Binding.constant(false),
|
||||||
height: Binding.constant(100),
|
height: Binding.constant(100),
|
||||||
focused: Binding.constant(false),
|
focused: Binding.constant(false),
|
||||||
alignment: TextAlignment.leading,
|
|
||||||
onImagesAdded: { _ in }
|
onImagesAdded: { _ in }
|
||||||
)
|
)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000
|
|||||||
|
|
||||||
struct SendMessageView: View {
|
struct SendMessageView: View {
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var sendMessage: (Int?) -> Void
|
var sendMessage: (Int?) -> Void
|
||||||
var sendLiveMessage: (() async -> Void)? = nil
|
var sendLiveMessage: (() async -> Void)? = nil
|
||||||
var updateLiveMessage: (() async -> Void)? = nil
|
var updateLiveMessage: (() async -> Void)? = nil
|
||||||
@@ -20,6 +21,7 @@ struct SendMessageView: View {
|
|||||||
var nextSendGrpInv: Bool = false
|
var nextSendGrpInv: Bool = false
|
||||||
var showVoiceMessageButton: Bool = true
|
var showVoiceMessageButton: Bool = true
|
||||||
var voiceMessageAllowed: Bool = true
|
var voiceMessageAllowed: Bool = true
|
||||||
|
var disableSendButton = false
|
||||||
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
|
var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other
|
||||||
var startVoiceMessageRecording: (() -> Void)? = nil
|
var startVoiceMessageRecording: (() -> Void)? = nil
|
||||||
var finishVoiceMessageRecording: (() -> Void)? = nil
|
var finishVoiceMessageRecording: (() -> Void)? = nil
|
||||||
@@ -48,18 +50,16 @@ struct SendMessageView: View {
|
|||||||
Text("Voice message…")
|
Text("Voice message…")
|
||||||
.font(teFont.italic())
|
.font(teFont.italic())
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
} else {
|
} else {
|
||||||
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
|
|
||||||
NativeTextEditor(
|
NativeTextEditor(
|
||||||
text: $composeState.message,
|
text: $composeState.message,
|
||||||
disableEditing: $composeState.inProgress,
|
disableEditing: $composeState.inProgress,
|
||||||
height: $teHeight,
|
height: $teHeight,
|
||||||
focused: $keyboardVisible,
|
focused: $keyboardVisible,
|
||||||
alignment: alignment,
|
|
||||||
onImagesAdded: onMediaAdded
|
onImagesAdded: onMediaAdded
|
||||||
)
|
)
|
||||||
.allowsTightening(false)
|
.allowsTightening(false)
|
||||||
@@ -109,6 +109,7 @@ struct SendMessageView: View {
|
|||||||
} else if showVoiceMessageButton
|
} else if showVoiceMessageButton
|
||||||
&& composeState.message.isEmpty
|
&& composeState.message.isEmpty
|
||||||
&& !composeState.editing
|
&& !composeState.editing
|
||||||
|
&& !composeState.forwarding
|
||||||
&& composeState.liveMessage == nil
|
&& composeState.liveMessage == nil
|
||||||
&& ((composeState.noPreview && vmrs == .noRecording)
|
&& ((composeState.noPreview && vmrs == .noRecording)
|
||||||
|| (vmrs == .recording && holdingVMR)) {
|
|| (vmrs == .recording && holdingVMR)) {
|
||||||
@@ -184,7 +185,8 @@ struct SendMessageView: View {
|
|||||||
!composeState.sendEnabled ||
|
!composeState.sendEnabled ||
|
||||||
composeState.inProgress ||
|
composeState.inProgress ||
|
||||||
(!voiceMessageAllowed && composeState.voicePreview) ||
|
(!voiceMessageAllowed && composeState.voicePreview) ||
|
||||||
composeState.endLiveDisabled
|
composeState.endLiveDisabled ||
|
||||||
|
disableSendButton
|
||||||
)
|
)
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
.contextMenu{
|
.contextMenu{
|
||||||
@@ -246,6 +248,7 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct RecordVoiceMessageButton: View {
|
private struct RecordVoiceMessageButton: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var startVoiceMessageRecording: (() -> Void)?
|
var startVoiceMessageRecording: (() -> Void)?
|
||||||
var finishVoiceMessageRecording: (() -> Void)?
|
var finishVoiceMessageRecording: (() -> Void)?
|
||||||
@Binding var holdingVMR: Bool
|
@Binding var holdingVMR: Bool
|
||||||
@@ -255,7 +258,7 @@ struct SendMessageView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: {}) {
|
Button(action: {}) {
|
||||||
Image(systemName: "mic.fill")
|
Image(systemName: "mic.fill")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.disabled(disabled)
|
.disabled(disabled)
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -308,7 +311,7 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "mic")
|
Image(systemName: "mic")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.disabled(composeState.inProgress)
|
.disabled(composeState.inProgress)
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -322,7 +325,7 @@ struct SendMessageView: View {
|
|||||||
Image(systemName: "multiply")
|
Image(systemName: "multiply")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.frame(width: 15, height: 15)
|
.frame(width: 15, height: 15)
|
||||||
}
|
}
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -339,7 +342,7 @@ struct SendMessageView: View {
|
|||||||
Image(systemName: "bolt.fill")
|
Image(systemName: "bolt.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
}
|
}
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
@@ -382,7 +385,7 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
||||||
while composeState.liveMessage != nil {
|
while await composeState.liveMessage != nil {
|
||||||
await update()
|
await update()
|
||||||
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
_ = try? await Task.sleep(nanoseconds: liveMsgInterval)
|
||||||
}
|
}
|
||||||
@@ -393,7 +396,7 @@ struct SendMessageView: View {
|
|||||||
private func finishVoiceMessageRecordingButton() -> some View {
|
private func finishVoiceMessageRecordingButton() -> some View {
|
||||||
Button(action: { finishVoiceMessageRecording?() }) {
|
Button(action: { finishVoiceMessageRecording?() }) {
|
||||||
Image(systemName: "stop.fill")
|
Image(systemName: "stop.fill")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.disabled(composeState.inProgress)
|
.disabled(composeState.inProgress)
|
||||||
.frame(width: 29, height: 29)
|
.frame(width: 29, height: 29)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import SimpleXChat
|
|||||||
struct ContactPreferencesView: View {
|
struct ContactPreferencesView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var contact: Contact
|
@Binding var contact: Contact
|
||||||
@State var featuresAllowed: ContactFeaturesAllowed
|
@State var featuresAllowed: ContactFeaturesAllowed
|
||||||
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||||
@@ -35,7 +36,7 @@ struct ContactPreferencesView: View {
|
|||||||
.disabled(currentFeaturesAllowed == featuresAllowed)
|
.disabled(currentFeaturesAllowed == featuresAllowed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modifier(BackButton {
|
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||||
if currentFeaturesAllowed == featuresAllowed {
|
if currentFeaturesAllowed == featuresAllowed {
|
||||||
dismiss()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
@@ -66,8 +67,8 @@ struct ContactPreferencesView: View {
|
|||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
infoRow("Contact allows", pref.contactPreference.allow.text)
|
infoRow("Contact allows", pref.contactPreference.allow.text)
|
||||||
}
|
}
|
||||||
header: { featureHeader(feature, enabled) }
|
header: { featureHeader(feature, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
footer: { featureFooter(feature, enabled) }
|
footer: { featureFooter(feature, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func timedMessagesFeatureSection() -> some View {
|
private func timedMessagesFeatureSection() -> some View {
|
||||||
@@ -102,8 +103,8 @@ struct ContactPreferencesView: View {
|
|||||||
infoRow("Delete after", timeText(pref.contactPreference.ttl))
|
infoRow("Delete after", timeText(pref.contactPreference.ttl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
header: { featureHeader(.timedMessages, enabled) }
|
header: { featureHeader(.timedMessages, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
footer: { featureFooter(.timedMessages, enabled) }
|
footer: { featureFooter(.timedMessages, enabled).foregroundColor(theme.colors.secondary) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
private func featureHeader(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||||
@@ -116,7 +117,6 @@ struct ContactPreferencesView: View {
|
|||||||
|
|
||||||
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||||
Text(feature.enabledDescription(enabled))
|
Text(feature.enabledDescription(enabled))
|
||||||
.frame(height: 36, alignment: .topLeading)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func savePreferences() {
|
private func savePreferences() {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ struct AddGroupMembersView: View {
|
|||||||
|
|
||||||
struct AddGroupMembersViewCommon: View {
|
struct AddGroupMembersViewCommon: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chat: Chat
|
var chat: Chat
|
||||||
@State var groupInfo: GroupInfo
|
@State var groupInfo: GroupInfo
|
||||||
var creatingGroup: Bool = false
|
var creatingGroup: Bool = false
|
||||||
@@ -70,7 +71,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
|
|
||||||
if (membersToAdd.isEmpty) {
|
if (membersToAdd.isEmpty) {
|
||||||
Text("No contacts to add")
|
Text("No contacts to add")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -90,16 +91,18 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) }
|
Button { selectedContacts.removeAll() } label: { Text("Clear").font(.caption) }
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(count) contact(s) selected")
|
Text("\(count) contact(s) selected")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No contacts selected")
|
Text("No contacts selected")
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
searchFieldView(text: $searchText, focussed: $searchFocussed)
|
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.primary, theme.colors.secondary)
|
||||||
.padding(.leading, 2)
|
.padding(.leading, 2)
|
||||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||||
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
||||||
@@ -125,6 +128,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
.onChange(of: selectedContacts) { _ in
|
.onChange(of: selectedContacts) { _ in
|
||||||
searchFocussed = false
|
searchFocussed = false
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func inviteMembersButton() -> some View {
|
private func inviteMembersButton() -> some View {
|
||||||
@@ -172,14 +176,14 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
var iconColor: Color
|
var iconColor: Color
|
||||||
if prohibitedToInviteIncognito {
|
if prohibitedToInviteIncognito {
|
||||||
icon = "theatermasks.circle.fill"
|
icon = "theatermasks.circle.fill"
|
||||||
iconColor = Color(uiColor: .tertiaryLabel)
|
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
|
||||||
} else {
|
} else {
|
||||||
if checked {
|
if checked {
|
||||||
icon = "checkmark.circle.fill"
|
icon = "checkmark.circle.fill"
|
||||||
iconColor = .accentColor
|
iconColor = theme.colors.primary
|
||||||
} else {
|
} else {
|
||||||
icon = "circle"
|
icon = "circle"
|
||||||
iconColor = Color(uiColor: .tertiaryLabel)
|
iconColor = Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Button {
|
return Button {
|
||||||
@@ -194,11 +198,10 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack{
|
HStack{
|
||||||
ProfileImage(imageStr: contact.image)
|
ProfileImage(imageStr: contact.image, size: 30)
|
||||||
.frame(width: 30, height: 30)
|
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
Text(ChatInfo.direct(contact: contact).chatViewName)
|
Text(ChatInfo.direct(contact: contact).chatViewName)
|
||||||
.foregroundColor(prohibitedToInviteIncognito ? .secondary : .primary)
|
.foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
@@ -208,7 +211,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding) -> some View {
|
func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding, _ onBackgroundColor: Color, _ secondaryColor: Color) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -217,7 +220,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding)
|
|||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
TextField("Search", text: text)
|
TextField("Search", text: text)
|
||||||
.focused(focussed)
|
.focused(focussed)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(onBackgroundColor)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -229,7 +232,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding)
|
|||||||
focussed.wrappedValue = false
|
focussed.wrappedValue = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(secondaryColor)
|
||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
|
|||||||
|
|
||||||
struct GroupChatInfoView: View {
|
struct GroupChatInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var groupInfo: GroupInfo
|
@Binding var groupInfo: GroupInfo
|
||||||
@ObservedObject private var alertManager = AlertManager.shared
|
|
||||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||||
@State private var groupLink: String?
|
@State private var groupLink: String?
|
||||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||||
@@ -37,6 +37,8 @@ struct GroupChatInfoView: View {
|
|||||||
case largeGroupReceiptsDisabled
|
case largeGroupReceiptsDisabled
|
||||||
case blockMemberAlert(mem: GroupMember)
|
case blockMemberAlert(mem: GroupMember)
|
||||||
case unblockMemberAlert(mem: GroupMember)
|
case unblockMemberAlert(mem: GroupMember)
|
||||||
|
case blockForAllAlert(mem: GroupMember)
|
||||||
|
case unblockForAllAlert(mem: GroupMember)
|
||||||
case removeMemberAlert(mem: GroupMember)
|
case removeMemberAlert(mem: GroupMember)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@ struct GroupChatInfoView: View {
|
|||||||
case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled"
|
case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled"
|
||||||
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
|
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
|
||||||
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
|
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
|
||||||
|
case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)"
|
||||||
|
case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)"
|
||||||
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
|
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
@@ -59,7 +63,7 @@ struct GroupChatInfoView: View {
|
|||||||
NavigationView {
|
NavigationView {
|
||||||
let members = chatModel.groupMembers
|
let members = chatModel.groupMembers
|
||||||
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
|
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
|
||||||
.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
|
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
|
||||||
|
|
||||||
List {
|
List {
|
||||||
groupInfoHeader()
|
groupInfoHeader()
|
||||||
@@ -78,13 +82,19 @@ struct GroupChatInfoView: View {
|
|||||||
} else {
|
} else {
|
||||||
sendReceiptsOptionDisabled()
|
sendReceiptsOptionDisabled()
|
||||||
}
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ChatWallpaperEditorSheet(chat: chat)
|
||||||
|
} label: {
|
||||||
|
Label("Chat theme", systemImage: "photo")
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("")
|
Text("")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Only group owners can change group preferences.")
|
Text("Only group owners can change group preferences.")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("\(members.count + 1) members") {
|
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||||
if groupInfo.canAddMembers {
|
if groupInfo.canAddMembers {
|
||||||
groupLinkButton()
|
groupLinkButton()
|
||||||
if (chat.chatInfo.incognito) {
|
if (chat.chatInfo.incognito) {
|
||||||
@@ -96,7 +106,7 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if members.count > 8 {
|
if members.count > 8 {
|
||||||
searchFieldView(text: $searchText, focussed: $searchFocussed)
|
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||||
@@ -126,12 +136,13 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if developerTools {
|
if developerTools {
|
||||||
Section(header: Text("For console")) {
|
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
@@ -144,6 +155,8 @@ struct GroupChatInfoView: View {
|
|||||||
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
|
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
|
||||||
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
|
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
|
||||||
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
|
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
|
||||||
|
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
|
||||||
|
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
|
||||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||||
}
|
}
|
||||||
@@ -167,8 +180,7 @@ struct GroupChatInfoView: View {
|
|||||||
private func groupInfoHeader() -> some View {
|
private func groupInfoHeader() -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
|
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||||
.frame(width: 192, height: 192)
|
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding()
|
.padding()
|
||||||
Text(cInfo.displayName)
|
Text(cInfo.displayName)
|
||||||
@@ -206,40 +218,64 @@ struct GroupChatInfoView: View {
|
|||||||
private struct MemberRowView: View {
|
private struct MemberRowView: View {
|
||||||
var groupInfo: GroupInfo
|
var groupInfo: GroupInfo
|
||||||
@ObservedObject var groupMember: GMember
|
@ObservedObject var groupMember: GMember
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
var user: Bool = false
|
var user: Bool = false
|
||||||
@Binding var alert: GroupChatInfoViewAlert?
|
@Binding var alert: GroupChatInfoViewAlert?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let member = groupMember.wrapped
|
let member = groupMember.wrapped
|
||||||
let v = HStack{
|
let v = HStack{
|
||||||
ProfileImage(imageStr: member.image)
|
ProfileImage(imageStr: member.image, size: 38)
|
||||||
.frame(width: 38, height: 38)
|
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
// TODO server connection status
|
// TODO server connection status
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
|
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
|
||||||
(member.verified ? memberVerifiedShield + t : t)
|
(member.verified ? memberVerifiedShield + t : t)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
let s = Text(member.memberStatus.shortText)
|
let s = Text(member.memberStatus.shortText)
|
||||||
(user ? Text ("you: ") + s : s)
|
(user ? Text ("you: ") + s : s)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
let role = member.memberRole
|
memberInfo(member)
|
||||||
if role == .owner || role == .admin {
|
|
||||||
Text(member.memberRole.text)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if user {
|
if user {
|
||||||
v
|
v
|
||||||
} else if member.canBeRemoved(groupInfo: groupInfo) {
|
} else if groupInfo.membership.memberRole >= .admin {
|
||||||
removeSwipe(member, blockSwipe(member, v))
|
// TODO if there are more actions, refactor with lists of swipeActions
|
||||||
|
let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
|
||||||
|
let canRemove = member.canBeRemoved(groupInfo: groupInfo)
|
||||||
|
if canBlockForAll && canRemove {
|
||||||
|
removeSwipe(member, blockForAllSwipe(member, v))
|
||||||
|
} else if canBlockForAll {
|
||||||
|
blockForAllSwipe(member, v)
|
||||||
|
} else if canRemove {
|
||||||
|
removeSwipe(member, v)
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
blockSwipe(member, v)
|
if !member.blockedByAdmin {
|
||||||
|
blockSwipe(member, v)
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
||||||
|
if member.blocked {
|
||||||
|
Text("blocked")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
} else {
|
||||||
|
let role = member.memberRole
|
||||||
|
if [.owner, .admin, .observer].contains(role) {
|
||||||
|
Text(member.memberRole.text)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,13 +285,31 @@ struct GroupChatInfoView: View {
|
|||||||
Button {
|
Button {
|
||||||
alert = .blockMemberAlert(mem: member)
|
alert = .blockMemberAlert(mem: member)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
|
Label("Block member", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
alert = .unblockMemberAlert(mem: member)
|
alert = .unblockMemberAlert(mem: member)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
|
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func blockForAllSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
|
||||||
|
v.swipeActions(edge: .leading) {
|
||||||
|
if member.blockedByAdmin {
|
||||||
|
Button {
|
||||||
|
alert = .unblockForAllAlert(mem: member)
|
||||||
|
} label: {
|
||||||
|
Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
alert = .blockForAllAlert(mem: member)
|
||||||
|
} label: {
|
||||||
|
Label("Block for all", systemImage: "hand.raised").foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,6 +325,14 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var memberVerifiedShield: Text {
|
||||||
|
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||||
|
.font(.caption)
|
||||||
|
.baselineOffset(2)
|
||||||
|
.kerning(-2)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func memberInfoView(_ groupMember: GMember) -> some View {
|
private func memberInfoView(_ groupMember: GMember) -> some View {
|
||||||
@@ -288,6 +350,7 @@ struct GroupChatInfoView: View {
|
|||||||
creatingGroup: false
|
creatingGroup: false
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Group link")
|
.navigationBarTitle("Group link")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
if groupLink == nil {
|
if groupLink == nil {
|
||||||
@@ -305,6 +368,7 @@ struct GroupChatInfoView: View {
|
|||||||
groupProfile: groupInfo.groupProfile
|
groupProfile: groupInfo.groupProfile
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Group profile")
|
.navigationBarTitle("Group profile")
|
||||||
|
.modifier(ThemedBackground())
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Edit group profile", systemImage: "pencil")
|
Label("Edit group profile", systemImage: "pencil")
|
||||||
@@ -313,8 +377,13 @@ struct GroupChatInfoView: View {
|
|||||||
|
|
||||||
private func addOrEditWelcomeMessage() -> some View {
|
private func addOrEditWelcomeMessage() -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
GroupWelcomeView(groupId: groupInfo.groupId, groupInfo: $groupInfo)
|
GroupWelcomeView(
|
||||||
|
groupInfo: $groupInfo,
|
||||||
|
groupProfile: groupInfo.groupProfile,
|
||||||
|
welcomeText: groupInfo.groupProfile.description ?? ""
|
||||||
|
)
|
||||||
.navigationTitle("Welcome message")
|
.navigationTitle("Welcome message")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
groupInfo.groupProfile.description == nil
|
groupInfo.groupProfile.description == nil
|
||||||
@@ -469,6 +538,7 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
|
|||||||
creatingGroup: creatingGroup
|
creatingGroup: creatingGroup
|
||||||
)
|
)
|
||||||
.navigationBarTitle("Group preferences")
|
.navigationBarTitle("Group preferences")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
} label: {
|
} label: {
|
||||||
if creatingGroup {
|
if creatingGroup {
|
||||||
@@ -479,14 +549,6 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var memberVerifiedShield: Text {
|
|
||||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
|
||||||
.font(.caption)
|
|
||||||
.baselineOffset(2)
|
|
||||||
.kerning(-2)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cantInviteIncognitoAlert() -> Alert {
|
func cantInviteIncognitoAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Can't invite contacts!"),
|
title: Text("Can't invite contacts!"),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct GroupLinkView: View {
|
|||||||
var linkCreatedCb: (() -> Void)? = nil
|
var linkCreatedCb: (() -> Void)? = nil
|
||||||
@State private var creatingLink = false
|
@State private var creatingLink = false
|
||||||
@State private var alert: GroupLinkAlert?
|
@State private var alert: GroupLinkAlert?
|
||||||
|
@State private var shouldCreate = true
|
||||||
|
|
||||||
private enum GroupLinkAlert: Identifiable {
|
private enum GroupLinkAlert: Identifiable {
|
||||||
case deleteLink
|
case deleteLink
|
||||||
@@ -70,6 +71,7 @@ struct GroupLinkView: View {
|
|||||||
}
|
}
|
||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
SimpleXLinkQRCode(uri: groupLink)
|
SimpleXLinkQRCode(uri: groupLink)
|
||||||
|
.id("simplex-qrcode-view-for-\(groupLink)")
|
||||||
Button {
|
Button {
|
||||||
showShareSheet(items: [simplexChatLink(groupLink)])
|
showShareSheet(items: [simplexChatLink(groupLink)])
|
||||||
} label: {
|
} label: {
|
||||||
@@ -125,11 +127,13 @@ struct GroupLinkView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if groupLink == nil && !creatingLink {
|
if groupLink == nil && !creatingLink && shouldCreate {
|
||||||
createGroupLink()
|
createGroupLink()
|
||||||
}
|
}
|
||||||
|
shouldCreate = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createGroupLink() {
|
private func createGroupLink() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct GroupMemberInfoView: View {
|
struct GroupMemberInfoView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@State var groupInfo: GroupInfo
|
@State var groupInfo: GroupInfo
|
||||||
@ObservedObject var groupMember: GMember
|
@ObservedObject var groupMember: GMember
|
||||||
@@ -27,24 +28,30 @@ struct GroupMemberInfoView: View {
|
|||||||
enum GroupMemberInfoViewAlert: Identifiable {
|
enum GroupMemberInfoViewAlert: Identifiable {
|
||||||
case blockMemberAlert(mem: GroupMember)
|
case blockMemberAlert(mem: GroupMember)
|
||||||
case unblockMemberAlert(mem: GroupMember)
|
case unblockMemberAlert(mem: GroupMember)
|
||||||
|
case blockForAllAlert(mem: GroupMember)
|
||||||
|
case unblockForAllAlert(mem: GroupMember)
|
||||||
case removeMemberAlert(mem: GroupMember)
|
case removeMemberAlert(mem: GroupMember)
|
||||||
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
|
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
|
||||||
case switchAddressAlert
|
case switchAddressAlert
|
||||||
case abortSwitchAddressAlert
|
case abortSwitchAddressAlert
|
||||||
case syncConnectionForceAlert
|
case syncConnectionForceAlert
|
||||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||||
|
case queueInfo(info: String)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
|
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
|
||||||
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
|
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
|
||||||
|
case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)"
|
||||||
|
case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)"
|
||||||
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
|
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
|
||||||
case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)"
|
case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)"
|
||||||
case .switchAddressAlert: return "switchAddressAlert"
|
case .switchAddressAlert: return "switchAddressAlert"
|
||||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||||
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
||||||
|
case let .queueInfo(info): return "queueInfo \(info)"
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +86,7 @@ struct GroupMemberInfoView: View {
|
|||||||
Section {
|
Section {
|
||||||
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
|
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
|
||||||
knownDirectChatButton(chat)
|
knownDirectChatButton(chat)
|
||||||
} else if groupInfo.fullGroupPreferences.directMessages.on {
|
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||||
if let contactId = member.memberContactId {
|
if let contactId = member.memberContactId {
|
||||||
newDirectChatButton(contactId)
|
newDirectChatButton(contactId)
|
||||||
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
||||||
@@ -106,7 +113,7 @@ struct GroupMemberInfoView: View {
|
|||||||
Label("Share address", systemImage: "square.and.arrow.up")
|
Label("Share address", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
if let contactId = member.memberContactId {
|
if let contactId = member.memberContactId {
|
||||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on {
|
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||||
connectViaAddressButton(contactLink)
|
connectViaAddressButton(contactLink)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -114,12 +121,14 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Address")
|
Text("Address")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Member") {
|
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Group", groupInfo.displayName)
|
infoRow("Group", groupInfo.displayName)
|
||||||
|
|
||||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||||
@@ -141,7 +150,7 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let connStats = connectionStats {
|
if let connStats = connectionStats {
|
||||||
Section("Servers") {
|
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||||
// TODO network connection status
|
// TODO network connection status
|
||||||
Button("Change receiving address") {
|
Button("Change receiving address") {
|
||||||
alert = .switchAddressAlert
|
alert = .switchAddressAlert
|
||||||
@@ -159,26 +168,33 @@ struct GroupMemberInfoView: View {
|
|||||||
|| connStats.ratchetSyncSendProhibited
|
|| connStats.ratchetSyncSendProhibited
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer })
|
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer })
|
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
if groupInfo.membership.memberRole >= .admin {
|
||||||
if member.memberSettings.showMessages {
|
adminDestructiveSection(member)
|
||||||
blockMemberButton(member)
|
} else {
|
||||||
} else {
|
nonAdminBlockSection(member)
|
||||||
unblockMemberButton(member)
|
|
||||||
}
|
|
||||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
|
||||||
removeMemberButton(member)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if developerTools {
|
if developerTools {
|
||||||
Section("For console") {
|
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Local name", member.localDisplayName)
|
infoRow("Local name", member.localDisplayName)
|
||||||
infoRow("Database ID", "\(member.groupMemberId)")
|
infoRow("Database ID", "\(member.groupMemberId)")
|
||||||
|
Button ("Debug delivery") {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
|
||||||
|
await MainActor.run { alert = .queueInfo(info: info) }
|
||||||
|
} catch let e {
|
||||||
|
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||||
|
let a = getErrorAlert(e, "Error")
|
||||||
|
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,17 +204,19 @@ struct GroupMemberInfoView: View {
|
|||||||
// this condition prevents re-setting picker
|
// this condition prevents re-setting picker
|
||||||
if !justOpened { return }
|
if !justOpened { return }
|
||||||
}
|
}
|
||||||
newRole = member.memberRole
|
|
||||||
do {
|
|
||||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
|
||||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
|
||||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
|
||||||
connectionStats = stats
|
|
||||||
connectionCode = code
|
|
||||||
} catch let error {
|
|
||||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
|
||||||
}
|
|
||||||
justOpened = false
|
justOpened = false
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
newRole = member.memberRole
|
||||||
|
do {
|
||||||
|
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||||
|
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||||
|
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||||
|
connectionStats = stats
|
||||||
|
connectionCode = code
|
||||||
|
} catch let error {
|
||||||
|
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: newRole) { newRole in
|
.onChange(of: newRole) { newRole in
|
||||||
if newRole != member.memberRole {
|
if newRole != member.memberRole {
|
||||||
@@ -214,12 +232,15 @@ struct GroupMemberInfoView: View {
|
|||||||
switch(alertItem) {
|
switch(alertItem) {
|
||||||
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
|
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
|
||||||
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
|
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
|
||||||
|
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
|
||||||
|
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
|
||||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||||
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
|
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
|
||||||
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
||||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||||
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
||||||
|
case let .queueInfo(info): return queueInfoAlert(info)
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,6 +250,7 @@ struct GroupMemberInfoView: View {
|
|||||||
ProgressView().scaleEffect(2)
|
ProgressView().scaleEffect(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectViaAddressButton(_ contactLink: String) -> some View {
|
func connectViaAddressButton(_ contactLink: String) -> some View {
|
||||||
@@ -302,14 +324,13 @@ struct GroupMemberInfoView: View {
|
|||||||
|
|
||||||
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
ProfileImage(imageStr: mem.image, color: Color(uiColor: .tertiarySystemFill))
|
ProfileImage(imageStr: mem.image, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||||
.frame(width: 192, height: 192)
|
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding()
|
.padding()
|
||||||
if mem.verified {
|
if mem.verified {
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "checkmark.shield"))
|
Text(Image(systemName: "checkmark.shield"))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
+ Text(" ")
|
+ Text(" ")
|
||||||
+ Text(mem.displayName)
|
+ Text(mem.displayName)
|
||||||
@@ -357,6 +378,7 @@ struct GroupMemberInfoView: View {
|
|||||||
)
|
)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationTitle("Security code")
|
.navigationTitle("Security code")
|
||||||
|
.modifier(ThemedBackground())
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
member.verified ? "View security code" : "Verify security code",
|
member.verified ? "View security code" : "Verify security code",
|
||||||
@@ -383,6 +405,55 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func adminDestructiveSection(_ mem: GroupMember) -> some View {
|
||||||
|
let canBlockForAll = mem.canBlockForAll(groupInfo: groupInfo)
|
||||||
|
let canRemove = mem.canBeRemoved(groupInfo: groupInfo)
|
||||||
|
if canBlockForAll || canRemove {
|
||||||
|
Section {
|
||||||
|
if canBlockForAll {
|
||||||
|
if mem.blockedByAdmin {
|
||||||
|
unblockForAllButton(mem)
|
||||||
|
} else {
|
||||||
|
blockForAllButton(mem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if canRemove {
|
||||||
|
removeMemberButton(mem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nonAdminBlockSection(_ mem: GroupMember) -> some View {
|
||||||
|
Section {
|
||||||
|
if mem.blockedByAdmin {
|
||||||
|
Label("Blocked by admin", systemImage: "hand.raised")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
} else if mem.memberSettings.showMessages {
|
||||||
|
blockMemberButton(mem)
|
||||||
|
} else {
|
||||||
|
unblockMemberButton(mem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func blockForAllButton(_ mem: GroupMember) -> some View {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
alert = .blockForAllAlert(mem: mem)
|
||||||
|
} label: {
|
||||||
|
Label("Block for all", systemImage: "hand.raised")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unblockForAllButton(_ mem: GroupMember) -> some View {
|
||||||
|
Button {
|
||||||
|
alert = .unblockForAllAlert(mem: mem)
|
||||||
|
} label: {
|
||||||
|
Label("Unblock for all", systemImage: "hand.raised.slash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func blockMemberButton(_ mem: GroupMember) -> some View {
|
private func blockMemberButton(_ mem: GroupMember) -> some View {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
alert = .blockMemberAlert(mem: mem)
|
alert = .blockMemberAlert(mem: mem)
|
||||||
@@ -558,6 +629,41 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||||
|
Alert(
|
||||||
|
title: Text("Block member for all?"),
|
||||||
|
message: Text("All new messages from \(mem.chatViewName) will be hidden!"),
|
||||||
|
primaryButton: .destructive(Text("Block for all")) {
|
||||||
|
blockMemberForAll(gInfo, mem, true)
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||||
|
Alert(
|
||||||
|
title: Text("Unblock member for all?"),
|
||||||
|
message: Text("Messages from \(mem.chatViewName) will be shown!"),
|
||||||
|
primaryButton: .default(Text("Unblock for all")) {
|
||||||
|
blockMemberForAll(gInfo, mem, false)
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked)
|
||||||
|
await MainActor.run {
|
||||||
|
_ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("apiBlockMemberForAll error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct GroupMemberInfoView_Previews: PreviewProvider {
|
struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
GroupMemberInfoView(
|
GroupMemberInfoView(
|
||||||
|
|||||||
@@ -9,9 +9,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
|
private let featureRoles: [(role: GroupMemberRole?, text: LocalizedStringKey)] = [
|
||||||
|
(nil, "all members"),
|
||||||
|
(.admin, "admins"),
|
||||||
|
(.owner, "owners")
|
||||||
|
]
|
||||||
|
|
||||||
struct GroupPreferencesView: View {
|
struct GroupPreferencesView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Binding var groupInfo: GroupInfo
|
@Binding var groupInfo: GroupInfo
|
||||||
@State var preferences: FullGroupPreferences
|
@State var preferences: FullGroupPreferences
|
||||||
@State var currentPreferences: FullGroupPreferences
|
@State var currentPreferences: FullGroupPreferences
|
||||||
@@ -24,10 +31,12 @@ struct GroupPreferencesView: View {
|
|||||||
List {
|
List {
|
||||||
featureSection(.timedMessages, $preferences.timedMessages.enable)
|
featureSection(.timedMessages, $preferences.timedMessages.enable)
|
||||||
featureSection(.fullDelete, $preferences.fullDelete.enable)
|
featureSection(.fullDelete, $preferences.fullDelete.enable)
|
||||||
featureSection(.directMessages, $preferences.directMessages.enable)
|
featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role)
|
||||||
featureSection(.reactions, $preferences.reactions.enable)
|
featureSection(.reactions, $preferences.reactions.enable)
|
||||||
featureSection(.voice, $preferences.voice.enable)
|
featureSection(.voice, $preferences.voice.enable, $preferences.voice.role)
|
||||||
featureSection(.files, $preferences.files.enable)
|
featureSection(.files, $preferences.files.enable, $preferences.files.role)
|
||||||
|
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
|
||||||
|
featureSection(.history, $preferences.history.enable)
|
||||||
|
|
||||||
if groupInfo.canEdit {
|
if groupInfo.canEdit {
|
||||||
Section {
|
Section {
|
||||||
@@ -47,7 +56,7 @@ struct GroupPreferencesView: View {
|
|||||||
preferences.timedMessages.ttl = currentPreferences.timedMessages.ttl
|
preferences.timedMessages.ttl = currentPreferences.timedMessages.ttl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modifier(BackButton {
|
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||||
if currentPreferences == preferences {
|
if currentPreferences == preferences {
|
||||||
dismiss()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
@@ -63,9 +72,9 @@ struct GroupPreferencesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>) -> some View {
|
private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding<GroupFeatureEnabled>, _ enableForRole: Binding<GroupMemberRole?>? = nil) -> some View {
|
||||||
Section {
|
Section {
|
||||||
let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary
|
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary
|
||||||
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
|
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
|
||||||
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
|
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
|
||||||
if groupInfo.canEdit {
|
if groupInfo.canEdit {
|
||||||
@@ -86,6 +95,14 @@ struct GroupPreferencesView: View {
|
|||||||
)
|
)
|
||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
}
|
}
|
||||||
|
if enableFeature.wrappedValue == .on, let enableForRole {
|
||||||
|
Picker("Enabled for", selection: enableForRole) {
|
||||||
|
ForEach(featureRoles, id: \.role) { fr in
|
||||||
|
Text(fr.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
settingsRow(icon, color: color) {
|
settingsRow(icon, color: color) {
|
||||||
infoRow(Text(feature.text), enableFeature.wrappedValue.text)
|
infoRow(Text(feature.text), enableFeature.wrappedValue.text)
|
||||||
@@ -93,10 +110,26 @@ struct GroupPreferencesView: View {
|
|||||||
if timedOn {
|
if timedOn {
|
||||||
infoRow("Delete after", timeText(preferences.timedMessages.ttl))
|
infoRow("Delete after", timeText(preferences.timedMessages.ttl))
|
||||||
}
|
}
|
||||||
|
if enableFeature.wrappedValue == .on, let enableForRole {
|
||||||
|
HStack {
|
||||||
|
Text("Enabled for").foregroundColor(theme.colors.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(
|
||||||
|
featureRoles.first(where: { fr in fr.role == enableForRole.wrappedValue })?.text
|
||||||
|
?? "all members"
|
||||||
|
)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||||
.frame(height: 36, alignment: .topLeading)
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
.onChange(of: enableFeature.wrappedValue) { enabled in
|
||||||
|
if case .off = enabled {
|
||||||
|
enableForRole?.wrappedValue = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,10 @@ struct GroupProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showImagePicker) {
|
.sheet(isPresented: $showImagePicker) {
|
||||||
LibraryImagePicker(image: $chosenImage) {
|
LibraryImagePicker(image: $chosenImage) { _ in
|
||||||
didSelectItem in showImagePicker = false
|
await MainActor.run {
|
||||||
|
showImagePicker = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: chosenImage) { image in
|
.onChange(of: chosenImage) { image in
|
||||||
|
|||||||
@@ -11,29 +11,33 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct GroupWelcomeView: View {
|
struct GroupWelcomeView: View {
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@EnvironmentObject private var m: ChatModel
|
@EnvironmentObject var theme: AppTheme
|
||||||
var groupId: Int64
|
|
||||||
@Binding var groupInfo: GroupInfo
|
@Binding var groupInfo: GroupInfo
|
||||||
@State private var welcomeText: String = ""
|
@State var groupProfile: GroupProfile
|
||||||
|
@State var welcomeText: String
|
||||||
@State private var editMode = true
|
@State private var editMode = true
|
||||||
@FocusState private var keyboardVisible: Bool
|
@FocusState private var keyboardVisible: Bool
|
||||||
@State private var showSaveDialog = false
|
@State private var showSaveDialog = false
|
||||||
|
|
||||||
|
let maxByteCount = 1200
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
if groupInfo.canEdit {
|
if groupInfo.canEdit {
|
||||||
editorView()
|
editorView()
|
||||||
.modifier(BackButton {
|
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||||
if welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil) {
|
if welcomeTextUnchanged() {
|
||||||
dismiss()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
showSaveDialog = true
|
showSaveDialog = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.confirmationDialog("Save welcome message?", isPresented: $showSaveDialog) {
|
.confirmationDialog(
|
||||||
Button("Save and update group profile") {
|
welcomeTextFitsLimit() ? "Save welcome message?" : "Welcome message is too long",
|
||||||
save()
|
isPresented: $showSaveDialog
|
||||||
dismiss()
|
) {
|
||||||
|
if welcomeTextFitsLimit() {
|
||||||
|
Button("Save and update group profile") { save() }
|
||||||
}
|
}
|
||||||
Button("Exit without saving") { dismiss() }
|
Button("Exit without saving") { dismiss() }
|
||||||
}
|
}
|
||||||
@@ -47,15 +51,15 @@ struct GroupWelcomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
welcomeText = groupInfo.groupProfile.description ?? ""
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
keyboardVisible = true
|
keyboardVisible = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func textPreview() -> some View {
|
private func textPreview() -> some View {
|
||||||
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil)
|
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||||
.allowsHitTesting(false)
|
.frame(minHeight: 130, alignment: .topLeading)
|
||||||
.frame(minHeight: 140, alignment: .topLeading)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ struct GroupWelcomeView: View {
|
|||||||
Group {
|
Group {
|
||||||
if welcomeText.isEmpty {
|
if welcomeText.isEmpty {
|
||||||
TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder")))
|
TextEditor(text: Binding.constant(NSLocalizedString("Enter welcome message…", comment: "placeholder")))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
}
|
}
|
||||||
TextEditor(text: $welcomeText)
|
TextEditor(text: $welcomeText)
|
||||||
@@ -75,7 +79,7 @@ struct GroupWelcomeView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, -5)
|
.padding(.horizontal, -5)
|
||||||
.padding(.top, -8)
|
.padding(.top, -8)
|
||||||
.frame(height: 140, alignment: .topLeading)
|
.frame(height: 130, alignment: .topLeading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -94,6 +98,9 @@ struct GroupWelcomeView: View {
|
|||||||
}
|
}
|
||||||
.disabled(welcomeText.isEmpty)
|
.disabled(welcomeText.isEmpty)
|
||||||
copyButton()
|
copyButton()
|
||||||
|
} footer: {
|
||||||
|
Text(!welcomeTextFitsLimit() ? "Message too large" : "")
|
||||||
|
.foregroundColor(.red)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -114,7 +121,15 @@ struct GroupWelcomeView: View {
|
|||||||
Button("Save and update group profile") {
|
Button("Save and update group profile") {
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
.disabled(welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil))
|
.disabled(welcomeTextUnchanged() || !welcomeTextFitsLimit())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func welcomeTextUnchanged() -> Bool {
|
||||||
|
welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func welcomeTextFitsLimit() -> Bool {
|
||||||
|
chatJsonLength(welcomeText) <= maxByteCount
|
||||||
}
|
}
|
||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
@@ -124,11 +139,13 @@ struct GroupWelcomeView: View {
|
|||||||
if welcome?.count == 0 {
|
if welcome?.count == 0 {
|
||||||
welcome = nil
|
welcome = nil
|
||||||
}
|
}
|
||||||
var groupProfileUpdated = groupInfo.groupProfile
|
groupProfile.description = welcome
|
||||||
groupProfileUpdated.description = welcome
|
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
|
||||||
groupInfo = try await apiUpdateGroup(groupId, groupProfileUpdated)
|
await MainActor.run {
|
||||||
m.updateGroup(groupInfo)
|
groupInfo = gInfo
|
||||||
welcomeText = welcome ?? ""
|
ChatModel.shared.updateGroup(gInfo)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("apiUpdateGroup error: \(responseError(error))")
|
logger.error("apiUpdateGroup error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
@@ -138,6 +155,6 @@ struct GroupWelcomeView: View {
|
|||||||
|
|
||||||
struct GroupWelcomeView_Previews: PreviewProvider {
|
struct GroupWelcomeView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
GroupWelcomeView(groupId: 1, groupInfo: Binding.constant(GroupInfo.sampleData))
|
GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
//
|
||||||
|
// ReverseList.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Levitating Pineapple on 11/06/2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// A List, which displays it's items in reverse order - from bottom to top
|
||||||
|
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
|
||||||
|
|
||||||
|
let items: Array<Item>
|
||||||
|
|
||||||
|
@Binding var scrollState: ReverseListScrollModel<Item>.State
|
||||||
|
|
||||||
|
/// Closure, that returns user interface for a given item
|
||||||
|
let content: (Item) -> Content
|
||||||
|
|
||||||
|
let loadPage: () -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> Controller {
|
||||||
|
Controller(representer: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ controller: Controller, context: Context) {
|
||||||
|
if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
|
||||||
|
switch destination {
|
||||||
|
case .nextPage:
|
||||||
|
controller.scrollToNextPage()
|
||||||
|
case let .item(id):
|
||||||
|
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
||||||
|
case .bottom:
|
||||||
|
controller.scroll(to: .zero, position: .top)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
controller.update(items: items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controller, which hosts SwiftUI cells
|
||||||
|
class Controller: UITableViewController {
|
||||||
|
private enum Section { case main }
|
||||||
|
private let representer: ReverseList
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||||
|
private var itemCount: Int = .zero
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(representer: ReverseList) {
|
||||||
|
self.representer = representer
|
||||||
|
super.init(style: .plain)
|
||||||
|
|
||||||
|
// 1. Style
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.transform = .verticalFlip
|
||||||
|
|
||||||
|
// 2. Register cells
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
tableView.register(
|
||||||
|
UITableViewCell.self,
|
||||||
|
forCellReuseIdentifier: cellReuseId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tableView.register(
|
||||||
|
HostingCell<Content>.self,
|
||||||
|
forCellReuseIdentifier: cellReuseId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Configure data source
|
||||||
|
self.dataSource = UITableViewDiffableDataSource<Section, Item>(
|
||||||
|
tableView: tableView
|
||||||
|
) { (tableView, indexPath, item) -> UITableViewCell? in
|
||||||
|
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
|
||||||
|
self.representer.loadPage()
|
||||||
|
}
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
|
||||||
|
.margins(.all, .zero)
|
||||||
|
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||||
|
} else {
|
||||||
|
if let cell = cell as? HostingCell<Content> {
|
||||||
|
cell.set(content: self.representer.content(item), parent: self)
|
||||||
|
} else {
|
||||||
|
fatalError("Unexpected Cell Type for: \(item)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell.transform = .verticalFlip
|
||||||
|
cell.selectionStyle = .none
|
||||||
|
cell.backgroundColor = .clear
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. External state changes will require manual layout updates
|
||||||
|
NotificationCenter.default
|
||||||
|
.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(updateLayout),
|
||||||
|
name: notificationName,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
deinit { NotificationCenter.default.removeObserver(self) }
|
||||||
|
|
||||||
|
@objc private func updateLayout() {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
tableView.setNeedsLayout()
|
||||||
|
tableView.layoutIfNeeded()
|
||||||
|
} else {
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides keyboard, when user begins to scroll.
|
||||||
|
/// Equivalent to `.scrollDismissesKeyboard(.immediately)`
|
||||||
|
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||||
|
UIApplication.shared
|
||||||
|
.sendAction(
|
||||||
|
#selector(UIResponder.resignFirstResponder),
|
||||||
|
to: nil,
|
||||||
|
from: nil,
|
||||||
|
for: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls up
|
||||||
|
func scrollToNextPage() {
|
||||||
|
tableView.setContentOffset(
|
||||||
|
CGPoint(
|
||||||
|
x: tableView.contentOffset.x,
|
||||||
|
y: tableView.contentOffset.y + tableView.bounds.height
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
Task { representer.scrollState = .atDestination }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls to Item at index path
|
||||||
|
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
||||||
|
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
|
||||||
|
if let index {
|
||||||
|
var animated = false
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
animated = true
|
||||||
|
}
|
||||||
|
tableView.scrollToRow(
|
||||||
|
at: IndexPath(row: index, section: .zero),
|
||||||
|
at: position,
|
||||||
|
animated: animated
|
||||||
|
)
|
||||||
|
Task { representer.scrollState = .atDestination }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(items: Array<Item>) {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
snapshot.appendItems(items)
|
||||||
|
dataSource.defaultRowAnimation = .none
|
||||||
|
dataSource.apply(
|
||||||
|
snapshot,
|
||||||
|
animatingDifferences: itemCount != .zero && abs(items.count - itemCount) == 1
|
||||||
|
)
|
||||||
|
itemCount = items.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UIHostingConfiguration` back-port for iOS14 and iOS15
|
||||||
|
/// Implemented as a `UITableViewCell` that wraps and manages a generic `UIHostingController`
|
||||||
|
private final class HostingCell<Hosted: View>: UITableViewCell {
|
||||||
|
private let hostingController = UIHostingController<Hosted?>(rootView: nil)
|
||||||
|
|
||||||
|
/// Updates content of the cell
|
||||||
|
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
||||||
|
func set(content: Hosted, parent: UIViewController) {
|
||||||
|
hostingController.rootView = content
|
||||||
|
if let hostingView = hostingController.view {
|
||||||
|
hostingView.invalidateIntrinsicContentSize()
|
||||||
|
if hostingController.parent != parent { parent.addChild(hostingController) }
|
||||||
|
if !contentView.subviews.contains(hostingController.view) {
|
||||||
|
contentView.addSubview(hostingController.view)
|
||||||
|
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
hostingView.leadingAnchor
|
||||||
|
.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
hostingView.trailingAnchor
|
||||||
|
.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
hostingView.topAnchor
|
||||||
|
.constraint(equalTo: contentView.topAnchor),
|
||||||
|
hostingView.bottomAnchor
|
||||||
|
.constraint(equalTo: contentView.bottomAnchor)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if hostingController.parent != parent { hostingController.didMove(toParent: parent) }
|
||||||
|
} else {
|
||||||
|
fatalError("Hosting View not loaded \(hostingController)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
hostingController.rootView = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages ``ReverseList`` scrolling
|
||||||
|
class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
|
||||||
|
/// Represents Scroll State of ``ReverseList``
|
||||||
|
enum State: Equatable {
|
||||||
|
enum Destination: Equatable {
|
||||||
|
case nextPage
|
||||||
|
case item(Item.ID)
|
||||||
|
case bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
case scrollingTo(Destination)
|
||||||
|
case atDestination
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var state: State = .atDestination
|
||||||
|
|
||||||
|
func scrollToNextPage() {
|
||||||
|
state = .scrollingTo(.nextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollToBottom() {
|
||||||
|
state = .scrollingTo(.bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollToItem(id: Item.ID) {
|
||||||
|
state = .scrollingTo(.item(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate let cellReuseId = "hostingCell"
|
||||||
|
|
||||||
|
fileprivate let notificationName = NSNotification.Name(rawValue: "reverseListNeedsLayout")
|
||||||
|
|
||||||
|
fileprivate extension CGAffineTransform {
|
||||||
|
/// Transform that vertically flips the view, preserving it's location
|
||||||
|
static let verticalFlip = CGAffineTransform(scaleX: 1, y: -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NotificationCenter {
|
||||||
|
static func postReverseListNeedsLayout() {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: notificationName,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable animation on iOS 15
|
||||||
|
func withConditionalAnimation<Result>(
|
||||||
|
_ animation: Animation? = .default,
|
||||||
|
_ body: () throws -> Result
|
||||||
|
) rethrows -> Result {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
try withAnimation(animation, body)
|
||||||
|
} else {
|
||||||
|
try body()
|
||||||
|
}
|
||||||
|
}
|
||||||